Skip to content

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 Rect and Container get 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.