Interaction & Accessibility¶
A YAML node with an on_click handler behaves like an MD3 button,
whether it's used with a mouse, a keyboard or a screen reader. Tesserae
draws the feedback itself.
- id: save
kind: Rect
style: {width: 120, height: 40, background: secondary_container, corner_radius: 20}
handlers: {on_click: "save"}
A button fragment works the same way: give its component: call the
handler, component: ButtonFilled with handlers: {on_click: "save"}
(see Handlers, bindings and names on a call).
The keyboard and assistive technology¶
A clickable node is focusable. Tab reaches it, and Enter or Space clicks
it, calling the same ViewModel method a mouse click does. It has
role="button", so assistive technology announces it as a button.
Links and text fields keep their own roles. Give a button a label, so
it's announced by name (see below).
On keyboard focus, a clickable node shows MD3's focus ring: 3 px in the
theme's secondary, 2 px outside the node, following its corners. Focus
from a click shows no ring.
The state layer and ripple¶
A clickable Rect or Container also gets MD3's state layer and ripple:
| State | What shows |
|---|---|
| Hovered | The node is tinted at 8%, even while the pointer is over one of its children. |
| Focused by keyboard | Tinted at 10%. Focus from a click shows nothing extra. |
| Pressed | A ripple spreads from the press point at 10% until it covers the node, and fades once the press ends. A quick tap still shows it for 225 ms. Enter or Space ripples from the centre. |
The tint is the theme's on_surface by default, and it follows theme
changes, including light and dark. The feedback paints over the node's
content, clipped to its rounded corners. The node itself isn't clipped,
so its children can still overflow it.
When clickable nodes are nested, only the innermost one responds. A
press ripples only that node, and its on_click handler is the only one
that runs, as MD3 expects: a button in a clickable card, or a checkbox
in a clickable list row, doesn't open the card or row as well. The
innermost handler stops the click from going further up the tree. This
applies to pointer, keyboard (Enter and Space) and simulated clicks, and
to right clicks, which open only the innermost context menu.
A child with no handler, such as a label or an icon inside a clickable card, doesn't stop the click, so it still reaches the card. A disabled control does stop the click: nothing fires, and the card doesn't open.
Choosing the colour, or turning it off¶
interaction: false # no state layer or ripple
interaction: {color: on_primary} # a theme role or a hex colour
interaction: true # feedback without an on_click
On a filled background, give the content's colour, such as on_primary
on primary, as MD3 does. interaction: works on Rect and
Container only. A Text or Icon can't hold the layer, so wrap it in
a clickable Container.
From code¶
view.interaction("save") returns the node's tesserae.interaction.Interaction,
or None if it has none. Its layer is the state layer node,
ripples lists the live ripple nodes, and ring is the focus ring
(ring_visible says whether it's showing). set_dragged(True) shows
MD3's dragged state (16%), for widgets that drag.
The opacities are MD3's. The ripple's timing is Material Web's: it grows over 450 ms with MD3's standard easing, fades in over 105 ms and fades out over 375 ms.
Disabled¶
Any node can be disabled, with a disabled: key or a binding:
id: root
kind: Container
style: {flex_direction: horizontal, gap: 8}
children:
- id: back
component: ButtonFilledTonal
with: {label: "Back", width: 96, height: 40, corner_radius: 20}
handlers: {on_click: "go_back"}
bindings: {disabled: "{{ not app.can_go_back.get() }}"}
a11y: {label: "Back"}
- id: save
kind: Rect
style: {width: 120, height: 40, background: primary, corner_radius: 20}
handlers: {on_click: "save"}
disabled: true
While it's disabled a node:
- is announced disabled, and isn't focusable (Tab skips it);
- shows no state layer, ripple or focus ring;
- runs none of its handlers. A click on it is still taken, as a disabled button swallows one, so a clickable card behind it doesn't get it;
- fades to 38% of its style's
opacity(MD3's disabled content opacity), and returns to it when enabled.
Enabled again, it's focusable as before: a clickable node is a Tab stop,
a plain one isn't. The state holds through a re-theme, a stylesheet
change and a reload. A control (Checkbox, Switch, SpinBox, ...)
takes the same key and binding for its own disabled, and draws its own
disabled look.
Labels and roles: a11y:¶
A node's a11y: field sets what assistive technology hears:
- id: save
kind: Rect
style: {width: 120, height: 40, background: secondary_container, corner_radius: 20}
handlers: {on_click: "save"}
a11y: {label: "Save"}
- id: title
kind: Text
text: {content: "Settings", font_family: Roboto, font_size: 22}
style: {foreground: on_surface}
a11y: {role: heading, level: 1}
| Field | What it does |
|---|---|
label |
The name read out, for a node whose content doesn't say it (an icon button, say). |
role |
One of tre's roles: button, tab, heading, listitem, img, group, none and so on. A clickable node is a button unless you give another role, and it stays focusable. Links and text fields have their own roles, so role isn't allowed on them. |
hidden |
true hides a decorative node from assistive technology. |
live |
polite or assertive, so changes to the node's content are announced. |
level |
A heading's level, from 1. |
expanded |
true or false on something that opens and closes (a disclosure, a menu button); leave it out on what cannot. |
selected |
true or false on one of a set (a tab, a list row). |
checked |
true or false on something that is on or off; null is neither. |
value, value_min, value_max, value_step |
A number and its range, for something that has one (a progress bar, a custom slider). |
On a TextField, the fields apply to its input; on a control (a
checkbox, a switch and so on), to the control, which sets its own
checked, selected and value, so those three are an error there.
A field you remove is reset when the view reloads. Anything else is an
error that names the widget.
Bound fields¶
Every field but role and live can follow the ViewModel: give the
field a {{ }} binding, as in bindings:, and it's kept up to date:
- id: inbox_status
kind: Text
text: {content: "Inbox", font_family: Roboto, font_size: 14}
style: {foreground: on_surface}
a11y: {label: "{{ unread_summary.get() }}", live: polite}
- id: details
kind: Container
a11y: {hidden: "{{ collapsed.get() }}"}
The whole value is the binding. To mix text with a value, build the
string in the ViewModel (a Computed, say:
self.unread_summary = Computed(lambda: f"{self.unread.get()} unread")).
A label must come out a string, hidden, expanded, selected and
checked true or false, a value a number, and level a whole number
from 1; None clears a label, a level, a state or a number (not hidden). A wrong value is an
error naming the widget and field. role and live can't be bound: a
role is what the node is, and live is how its changes are announced.
A bound label on a Link is its name, whatever its text says.
For widgets: tesserae.a11y¶
Tesserae's widgets describe themselves with tesserae.a11y:
from tesserae import a11y
a11y.describe(node, role="switch", label="Wi-Fi", checked=True)
undo = a11y.on_action(slider, {
"increment": lambda event: step(+1),
"set_value": lambda event: set_to(event.value),
})
describe checks every field before setting any of them. It takes the
YAML fields above plus checked, selected, expanded, disabled and
value/value_min/value_max/value_step. on_action routes the
requests a screen reader can make: increment, decrement, expand,
collapse, scroll_into_view and set_value. Activating (clicking)
isn't one of them: tre doesn't route it through a11y_action.
ROLES, LIVE and ACTIONS are tre's lists.
tre's disabled is announced only: a disabled node still takes focus
and clicks. Tesserae's controls make it behave: a disabled control
can't be focused or used, and draws in MD3's disabled colours.
bind keeps a widget's label, hidden, level, expanded, selected, checked or value (with its range) up to date, as a
bound YAML field does. Each is a Signal, a Computed, a
function of no arguments or a plain value:
from tesserae import Computed, a11y
stop = a11y.bind(inbox_button, label=Computed(lambda: f"{unread.get()} unread"),
hidden=collapsed)
It takes a node, or a widget or control (their .node), checks each
value as describe does, and returns the function that stops it. role
and live aren't bindable: set them once with describe.
Not yet covered¶
- Among plain YAML nodes, only
RectandContainerget the state layer, ripple and focus ring. The MD3 controls (checkbox, switch and so on) have their own (Controls). - The ring is placed when focus arrives. A node that resizes while it has focus keeps its old ring until focus moves.