Search documentation of Plushie

# Accessibility

Copy Markdown View Source

Plushie integrates with platform accessibility services via AccessKit: VoiceOver on macOS, AT-SPI/Orca on Linux, UI Automation/NVDA/JAWS on Windows. Most accessibility semantics are inferred automatically from widget types, so you get correct roles, labels, and state without extra work.

This reference covers how auto-inference works, when to use the `a11y` prop for explicit overrides, keyboard navigation, live regions, common patterns, and testing.

## Accessible by default

Plushie's vendored Iced fork includes extensive accessibility and keyboard navigation support built on top of Iced's rendering architecture. Built-in widgets expose accessibility metadata automatically: a button announces itself as a button, a checkbox tracks its checked state, a slider exposes its numeric value and range. Application code does not need to add accessibility attributes for standard widgets.

When you do need overrides (custom canvas controls, widgets with context-dependent labels, relationship annotations), the `a11y` prop is available on every widget.

## Auto-inference

### Role mapping

Widget type | Inferred role
--- | ---
`button` | button
`text`, `rich_text` | label
`text_input` | text_input
`text_editor` | multiline_text_input
`checkbox` | check_box
`toggler` | switch
`radio` | radio_button
`slider`, `vertical_slider` | slider
`pick_list`, `combo_box` | combo_box
`progress_bar` | progress_indicator
`scrollable` | scroll_view
`image`, `svg`, `qr_code` | image
`canvas` | canvas
`table` | table
`pane_grid` | group
`rule` | splitter
`window` | window
`markdown` | document
`tooltip` | tooltip
Containers (column, row, etc.) | generic_container

Layout containers use `generic_container`, which is filtered from the platform accessibility tree automatically. Screen reader users navigate through the semantic content (buttons, text, inputs) without encountering intermediate layout wrappers.

### Label inference

Widget type | Prop used as label
--- | ---
`button`, `checkbox`, `toggler`, `radio` | `label` prop
`text`, `rich_text` | `content` prop
`image`, `svg` | `alt` prop
`text_input` | `placeholder` prop (as description)

### State inference

State | Source
--- | ---
Disabled | `disabled: true` on any widget
Toggled | `checked` (checkbox), `is_toggled` (toggler)
Numeric value | `value` (slider, progress_bar)
Range | `range: [min, max]` (slider, progress_bar)
String value | `value` (text_input)
Selected | `selected` (pick_list)

## The a11y prop

Every widget accepts an `a11y:` prop for explicit overrides. Pass a map or keyword list:

    button("save", "Save", a11y: %{description: "Save the current document"})

    text_input("email", model.email,
      a11y: %{required: true, labelled_by: "email-label"}
    )

See `Plushie.Type.A11y` for the full struct definition.

### Fields

Field | Type | Purpose
--- | --- | ---
`role` | atom | Override the inferred role
`label` | string | Accessible name (override inferred label)
`description` | string | Longer description read after the label
`live` | `:polite | :assertive` | Live region announcement mode
`hidden` | boolean | Exclude from accessibility tree
`expanded` | boolean | Disclosure state (combobox, menu)
`required` | boolean | Form field is required
`level` | 1-6 | Heading level
`busy` | boolean | Suppress announcements during updates
`invalid` | boolean | Form validation error state
`modal` | boolean | Dialog is modal (traps focus)
`read_only` | boolean | Value is readable but not editable
`toggled` | boolean | Toggle/checked state
`selected` | boolean | Selection state
`value` | string | Current value for assistive technology
`orientation` | `:horizontal | :vertical` | Layout orientation hint
`disabled` | boolean | Disabled state override
`mnemonic` | string | Keyboard mnemonic (single character)
`position_in_set` | integer | 1-based position in a group
`size_of_set` | integer | Total items in the group
`has_popup` | string | Popup type: `"listbox"`, `"menu"`, `"dialog"`, `"tree"`, `"grid"`

### Cross-references

Field | Purpose
--- | ---
`labelled_by` | ID of the widget that provides this widget's label
`described_by` | ID of the widget that provides a description
`error_message` | ID of the widget showing the error message

Cross-reference IDs are resolved relative to the current scope during tree normalisation. A bare ID like `"label"` inside scope `"form"` resolves to `"form/label"`. See Scoped IDs.

### Roles

Roles are organised into categories:

Interactive: `button`, `check_box`, `combo_box`, `link`, `menu_item`, `radio_button`, `slider`, `switch`, `tab`, `text_input`, `multiline_text_input`, `tree_item`

Structure: `generic_container`, `group`, `heading`, `label`, `list`, `list_item`, `column_header`, `table_row`, `table_cell`, `table`, `tree`

Landmarks: `navigation`, `region`, `search`

Status: `alert`, `alert_dialog`, `dialog`, `status`, `meter`, `progress_indicator`

Other: `document`, `image`, `canvas`, `menu`, `menu_bar`, `scroll_view`, `separator`, `tab_list`, `tab_panel`, `toolbar`, `tooltip`, `window`

Aliases (normalised to canonical): `:cell` -> `:table_cell`, `:checkbox` -> `:check_box`, `:container` / `:generic` -> `:generic_container`, `:progress_bar` -> `:progress_indicator`, `:radio` -> `:radio_button`, `:row` -> `:table_row`, `:text_editor` -> `:multiline_text_input`

## Accessible name computation

When a screen reader encounters a widget, it announces the widget's accessible name. Getting this right is the most common accessibility concern. The name is determined in this order:

1. Direct label - if the `a11y: %{label: "..."}` prop or the widget's inferred label is set, that's the name.
2. Labelled-by - if no direct label, the framework checks `labelled_by`. For roles that support name-from-contents (button, checkbox, radio, link), descendant text content is used automatically.
3. No name - the screen reader announces only the role.

If a widget has no accessible name, screen readers say things like "button" with no context. Always ensure interactive widgets have either a label prop or a `labelled_by` reference.

## Keyboard navigation

Plushie has built-in keyboard navigation:

Key | Behaviour
--- | ---
Tab / Shift+Tab | Cycle focus through focusable widgets
Space / Enter | Activate the focused widget
Arrow keys | Navigate within sliders, lists, etc.
F6 / Shift+F6 | Cycle focus between pane_grid panes
Ctrl+Tab | Escape the current focus scope
Escape | Close popups, dismiss modals

Focus follows the focus-visible pattern: focus rings appear on keyboard navigation but not on mouse clicks.

### Canvas keyboard navigation

Canvas interactive elements can opt into keyboard focus with `focusable: true`:

    interactive "save-btn", on_click: true, focusable: true,
      a11y: %{role: :button, label: "Save"} do
      rect(0, 0, 100, 36, fill: "#3b82f6")
    end

`focusable: true` adds the element to the Tab order. Space/Enter activates it. Without `focusable: true`, the element responds to mouse clicks but is invisible to keyboard navigation and screen readers.

## Live regions

The `live:` field controls how screen readers announce dynamic content changes. Use it on widgets whose content updates while visible:

Value | Behaviour | Use for
--- | --- | ---
`:polite` | Announced after current speech finishes | Status messages, counters, progress updates
`:assertive` | Interrupts current speech immediately | Error messages, critical alerts

    text("status", model.status_message, a11y: %{live: :polite})
    text("error", model.error, a11y: %{live: :assertive, role: :alert})

Use `:assertive` sparingly. Rapid updates cause announcement storms. Prefer `:polite` for anything that updates more than once per user action.

Do not set `live:` on static content. The screen reader re-announces it on every tree rebuild even when the content hasn't changed.

## Disabled vs read-only

These are semantically different:

State | Meaning | Screen reader behaviour
--- | --- | ---
Disabled | Not currently usable | Often skipped in Tab navigation, announced as "dimmed" or "unavailable"
Read-only | Has a value that can be read but not changed | Fully navigable and announced, editing commands blocked

Use `disabled: true` for controls that become active based on other state (e.g. a Submit button disabled until required fields are filled). Use `read_only: true` for displaying values the user can select/copy but not edit.

## Common patterns

### Form field labelling

Every form control needs an accessible name. Three approaches:

Direct label (simplest):

    text_input("email", model.email,
      placeholder: "Email address",
      a11y: %{label: "Email address"}
    )

Cross-widget labelled_by:

    text("email-label", "Email address")
    text_input("email", model.email,
      a11y: %{labelled_by: "email-label"}
    )

Description for additional context:

    text_input("password", model.password,
      a11y: %{label: "Password", described_by: "password-hint"}
    )
    text("password-hint", "Must be at least 8 characters", size: 11)

### Grouping related controls

Use the `:group` role when controls are logically related and the grouping helps the user understand context:

    container "shipping-options",
      a11y: %{role: :group, label: "Shipping options"} do
      radio("standard", :standard, model.shipping, label: "Standard (5-7 days)")
      radio("express", :express, model.shipping, label: "Express (1-2 days)")
    end

Do not wrap things in groups unless the grouping adds semantic value. Layout containers (`column`, `row`) already use `generic_container` and are invisible to screen readers.

### Canvas accessibility

Canvas is a raw drawing surface. The renderer has no way to know that a group of shapes is meant to be a "button." You must provide explicit accessibility annotations:

    group "save-btn",
      on_click: true,
      cursor: :pointer,
      focusable: true,
      a11y: %{role: :button, label: "Save experiment"} do
      rect(0, 0, 100, 36, fill: "#3b82f6")
      text(50, 11, "Save", fill: "#fff", size: 14)
    end

Without `a11y` annotations, canvas elements are invisible to screen readers and keyboard navigation.

## Platform notes

Platform | AT service | Integration
--- | --- | ---
macOS | VoiceOver | Via AccessKit -> NSAccessibility
Linux | Orca (AT-SPI) | Via AccessKit -> AT-SPI2
Windows | NVDA / JAWS | Via AccessKit -> UI Automation

Assistive technology actions (e.g. VoiceOver "activate") produce the same `WidgetEvent` as direct interaction. No special handling needed in `update/2`.

### Screen reader differences

NVDA/JAWS (Windows) operate in two modes: browse mode (screen reader intercepts keys for virtual navigation) and focus mode (keys pass to the app). They auto-switch to focus mode when Tab reaches an interactive control.

VoiceOver (macOS) uses a rotor for category-based navigation (headings, buttons, form fields). Correct roles ensure widgets appear in the right rotor categories.

Orca (Linux) provides structural navigation similar to NVDA's browse mode. Known caveat: Wayland keyboard input is currently broken for screen readers, so Linux screen reader users need X11.

## Testing accessibility

    # Assert role
    assert_role("#save", "button")

    # Assert accessibility properties
    assert_a11y("#email", %{required: true, invalid: false})

    # Find by accessibility attributes
    find_by_role(:button)
    find_by_label("Save")

These assertions verify the accessibility tree, not just the visual output. They catch missing labels, wrong roles, and missing state annotations.

See the Testing reference for the full assertion API.

## See also

- `Plushie.Type.A11y` - full struct and field documentation
- Canvas reference - canvas accessibility annotations and interactive elements
- Scoped IDs reference - how `labelled_by` and `described_by` IDs are resolved
- AccessKit - the cross-platform accessibility library Plushie uses
- WAI-ARIA Authoring Practices - W3C patterns for accessible widget design
- WCAG 2.1 - Web Content Accessibility Guidelines (the standard Plushie targets)

← Previous Page WASM Deployment
Next Page → Animation