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.
| Platform | Nodes are exposed to |
|---|---|
| Web | Browser screen readers, through invisible ARIA elements laid over the canvas |
| Android | TalkBack and the other accessibility services, through an accessibility node provider |
| macOS | VoiceOver and Switch Control |
| Windows, Linux | Not 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.
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 ofbutton,checkbox,radio,switch,slider,textbox,list,listitem,heading,text,combobox,option,tab,image,group, orlink. A node without a role is agroup.label, what a screen reader announces, andvalue, the text of a field or a readable slider value.x,y,width, andheight, in physical canvas pixels: the coordinates you draw with.- The state flags
checked,selected,focused,disabled, andexpanded. Leavecheckedout on nodes that cannot be checked. - For sliders,
min,max, andrangeValue; the range applies whenmaxis 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:
// 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 alwaystrue, 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.