Accessibility #

Budo apps draw everything on a canvas, so the operating system cannot see their buttons, fields, or text on its own. sys.accessibility lets an app describe what it draws as a flat list of nodes, each with a role, a label, a position, and a state. Screen readers read and navigate those nodes, and the actions their users perform come back to the app.

If you build your interface with budo-ui, this is already done for you; the last section explains what it covers. Read on to describe a custom interface yourself.

PlatformNodes are exposed to
WebBrowser screen readers, through invisible ARIA elements laid over the canvas
AndroidTalkBack and the other accessibility services, through an accessibility node provider
macOSVoiceOver and Switch Control
Windows, LinuxNot available: isAvailable() returns false, and update() still accepts the nodes

Publishing nodes #

Call sys.accessibility.update(nodes) with the nodes in reading order. Calling it every frame is fine: the runtime republishes only when something changed.

main.js
sys.accessibility.update([
  // A heading that names the screen.
  { id: 'title', role: 'heading', label: 'Settings', x: 24, y: 20, width: 300, height: 32 },
  // A switch reports whether it is on.
  { id: 'dark', role: 'switch', label: 'Dark mode', checked: darkMode, x: 24, y: 70, width: 300, height: 44 },
  // A slider gives its range, its value, and a readable form of the value.
  { id: 'volume', role: 'slider', label: 'Volume', min: 0, max: 100, rangeValue: volume,
    value: volume + '%', x: 24, y: 120, width: 300, height: 44 },
  // A text field gives its text and whether it is being edited.
  { id: 'name', role: 'textbox', label: 'Name', value: name, focused: editing, x: 24, y: 170, width: 300, height: 44 },
  // A button that cannot be pressed yet.
  { id: 'save', role: 'button', label: 'Save', disabled: !changed, x: 24, y: 230, width: 120, height: 44 },
]);

Each node has these fields:

  • id, a string that stays the same across frames; actions refer to it.
  • role, one of button, checkbox, radio, switch, slider, textbox, list, listitem, heading, text, combobox, option, tab, image, group, or link. A node without a role is a group.
  • label, what a screen reader announces, and value, the text of a field or a readable slider value.
  • x, y, width, and height, in physical canvas pixels: the coordinates you draw with.
  • The state flags checked, selected, focused, disabled, and expanded. Leave checked out on nodes that cannot be checked.
  • For sliders, min, max, and rangeValue; the range applies when max is set.

update takes at most 512 nodes and throws a TypeError beyond that. Describe what is on screen rather than every row of a long list.

Handling actions #

Screen-reader users can press, focus, and change your controls. Each frame, take the actions they requested and apply them to your data:

main.js
// Return and clear the actions requested since the last call.
for (const { id, action, value } of sys.accessibility.takeActions()) {
  // Press: activate a button or flip a switch.
  if (action === 'press' && id === 'dark') darkMode = !darkMode;
  if (action === 'press' && id === 'save') save();
  // Increment and decrement: step a slider.
  if (action === 'increment' && id === 'volume') volume = Math.min(100, volume + 10);
  if (action === 'decrement' && id === 'volume') volume = Math.max(0, volume - 10);
  // Set value: replace a field's text with the given string.
  if (action === 'setValue' && id === 'name') name = value;
  // Focus: the user moved to a control; start editing a field.
  if (action === 'focus' && id === 'name') startEditing();
}

The actions are press, focus, increment, decrement, and setValue, which carries the new text in value. An app that ignores actions still reads correctly; it just cannot be operated from the screen reader.

When to build the tree #

An app with a large interface can skip building nodes when nothing will read them:

  • sys.accessibility.isAvailable() tells whether the platform exposes nodes at all.
  • sys.accessibility.isActive() tells whether a screen reader or another assistive service probably runs. On the web it is always true, because browsers do not reveal screen readers.

On macOS, set the environment variable BUDO_ACCESSIBILITY=1 to make isActive() return true, so you can inspect the tree with Accessibility Inspector without running VoiceOver.

Keyboard access #

Screen-reader users, and many others, rely on the keyboard. Let Tab and Shift+Tab move between controls, let Enter and Space activate them, and draw a visible focus indicator. Mark the focused control with focused: true so screen readers follow it.

Other languages #

Lua passes a table of node tables to sys.accessibility.update and receives a table from takeActions(). WebAssembly modules call accessibility_update(jsonPtr, jsonLen) with the nodes as a JSON array, and accessibility_take_actions(ptr, max), which writes the actions as a JSON array and returns its length; see the API reference.

With budo-ui #

budo-ui does all of this for you. Every widget describes itself with its role, label, state, and value, and screen-reader actions press buttons, flip switches, step sliders, and fill fields. Tab and Shift+Tab move a visible focus ring, and dialogs keep the focus inside until they close. For your own widgets, pass { a11y: { role, label, ... } } to ui.interact. See Building interfaces.