Packages

Native desktop GUIs from Elixir, powered by iced

Retired package: Renamed - Package has been renamed to plushie: hex.pm/packages/plushie

Current section

Files

Jump to
toddy docs accessibility.md
Raw

docs/accessibility.md

# Accessibility
Toddy provides built-in accessibility support via
[accesskit](https://github.com/AccessKit/accesskit), a cross-platform
accessibility toolkit. The default renderer build includes accessibility,
activating native platform APIs automatically: VoiceOver on macOS,
AT-SPI/Orca on Linux, and UI Automation/NVDA/JAWS on Windows.
Screen reader users, keyboard-only users, and other AT users interact with
the same widgets and receive the same events as mouse users. No special
event handling is needed in your `update/2` -- AT actions produce the same
`%Widget{type: :click, id: id}`, `%Widget{type: :input, id: id, value: val}`, etc. events as direct interaction.
## How it works
Iced's fork (`v0.14.0-a11y-accesskit` branch) provides native accessibility
support. Three pieces work together:
1. **iced widgets report `Accessible` metadata** -- each widget implements
the `Accessible` trait via iced's `operate()` mechanism. Widgets declare
their role, label, and state to the accessibility system automatically.
2. **TreeBuilder assembles the accesskit tree** -- `iced_winit::a11y`
contains a `TreeBuilder` that walks the widget tree during `operate()`,
collecting `Accessible` metadata and building an accesskit `TreeUpdate`.
This happens natively inside iced -- toddy does not build the tree.
3. **AT actions become native iced events** -- when an AT triggers an action
(e.g. a screen reader user activates a button), iced translates it to a
native event. The renderer maps it to a standard toddy event and sends it
to Elixir over the wire protocol.
```
Elixir app Renderer (iced) Platform AT
| | |
|--- UI tree (a11y) ----->| |
| |-- operate() + TreeBuilder -->|
| |-- TreeUpdate --------------->|
| | |
| |<-- AT Action (Click) --------|
| | (native iced event) |
|<-- %Widget{:click} -----| |
```
### toddy's role
toddy does not build its own accesskit tree. Iced handles tree building,
AT actions, and platform integration natively. toddy's contribution is the
`A11yOverride` wrapper widget (`a11y_widget.rs` in toddy) that
intercepts `operate()` to apply Elixir-side overrides from the `a11y` prop.
This means:
- **Standard widgets** get correct accessibility semantics automatically
from iced's own `Accessible` implementations.
- **Extension widgets** get free a11y support without any code -- they are
already iced `Element`s that participate in `operate()`.
- **The `a11y` prop** lets Elixir override or augment the inferred semantics
when auto-inference is insufficient.
- **`HiddenInterceptor`** is a companion wrapper that excludes widgets from
the AT tree when `hidden: true` is set.
Accessibility is compiled unconditionally -- there are no feature flags to
toggle it.
## Auto-inference
Most widgets get correct accessibility semantics without any annotation.
Iced automatically reports roles, labels, and state from widget types and
existing props via the `Accessible` trait.
### Role mapping
Every widget type maps to an accesskit role:
| Widget type | Role | Notes |
|---|---|---|
| `button` | Button | |
| `text`, `rich_text` | Label | |
| `text_input` | TextInput | |
| `text_editor` | MultilineTextInput | |
| `checkbox` | CheckBox | |
| `toggler` | Switch | |
| `radio` | RadioButton | |
| `slider`, `vertical_slider` | Slider | |
| `pick_list`, `combo_box` | ComboBox | |
| `progress_bar` | ProgressIndicator | |
| `scrollable` | ScrollView | |
| `container`, `column`, `row`, `stack` | GenericContainer | Also: `keyed_column`, `grid`, `float`, `pin`, `responsive`, `space`, `themer`, `mouse_area`, `sensor`, `overlay` |
| `window` | Window | |
| `image`, `svg`, `qr_code` | Image | |
| `canvas` | Canvas | |
| `table` | Table | |
| `tooltip` | Tooltip | |
| `markdown` | Document | |
| `pane_grid` | Group | |
| `rule` | Splitter | |
### Labels
Labels are the accessible name announced by screen readers. They are
extracted from the prop that makes sense for each widget type:
| Widget type | Label source |
|---|---|
| `button`, `checkbox`, `toggler`, `radio` | `label` prop |
| `text`, `rich_text` | `content` prop |
| `image`, `svg` | `alt` prop |
| `text_input` | `placeholder` prop (as description, not label) |
If a widget has no auto-inferred label and no `a11y` label override, the
screen reader sees the role with no name. This is fine for structural
containers but not for interactive widgets -- always give buttons, inputs,
and toggles either a visible label or an `a11y` label.
### State
Widget state is extracted from existing props automatically:
| State | Source | Widgets |
|---|---|---|
| Disabled | `disabled: true` | Any widget |
| Toggled | `checked` prop | `checkbox` |
| Toggled | `is_toggled` prop | `toggler` |
| Toggled | `selected` prop (boolean) | `radio` |
| Numeric value | `value` prop (number) | `slider`, `progress_bar` |
| Min/max | `range` prop (`[min, max]`) | `slider`, `progress_bar` |
| String value | `value` prop (string) | `text_input` |
| Selected item | `selected` prop (string) | `pick_list` |
## The a11y prop
For cases where auto-inference is insufficient, every widget accepts an
`a11y` prop -- a `Toddy.Iced.A11y` struct (or bare map) of fields that
override or augment the inferred semantics.
### Fields
| Field | Type | Description |
|---|---|---|
| `role` | `atom()` | Override the inferred role (see [available roles](#available-roles)) |
| `label` | `String.t()` | Accessible name (what the screen reader announces) |
| `description` | `String.t()` | Longer description (secondary announcement) |
| `live` | `:off \| :polite \| :assertive` | Live region -- AT announces content changes |
| `hidden` | `boolean()` | Exclude from accessibility tree entirely |
| `expanded` | `boolean()` | Expanded/collapsed state (menus, disclosures) |
| `required` | `boolean()` | Mark form field as required |
| `level` | `pos_integer()` | Heading level (1-6, only meaningful with `:heading` role) |
| `busy` | `boolean()` | Loading/processing state (AT announces when done) |
| `invalid` | `boolean()` | Form validation failure |
| `modal` | `boolean()` | Dialog is modal (AT restricts navigation to this container) |
| `read_only` | `boolean()` | Can be read but not edited |
| `mnemonic` | `String.t()` | Alt+letter keyboard shortcut (single character) |
| `toggled` | `boolean()` | Toggled/checked state (for custom toggle widgets) |
| `selected` | `boolean()` | Selected state (for custom selectable widgets) |
| `value` | `String.t()` | Current value as a string (for custom value-displaying widgets) |
| `orientation` | `:horizontal \| :vertical` | Orientation hint for AT navigation |
| `labelled_by` | `String.t()` | ID of the widget that labels this one |
| `described_by` | `String.t()` | ID of the widget that describes this one |
| `error_message` | `String.t()` | ID of the widget showing the error message |
The type is defined in `Toddy.Iced.A11y`. All fields are optional -- only
include what you need. Both structs and bare maps are accepted; bare maps
are normalized via `A11y.cast/1`.
### Using the a11y prop
With `Toddy.UI` (do-block syntax):
```elixir
import Toddy.UI
# Headings
text("title", "Welcome to MyApp", a11y: %A11y{role: :heading, level: 1})
text("settings_heading", "Settings", a11y: %A11y{role: :heading, level: 2})
# Icon buttons that need a label for screen readers
button("close", "X", a11y: %A11y{label: "Close dialog"})
# Landmark regions
container "search_results", a11y: %A11y{role: :region, label: "Search results"} do
# ...
end
# Live regions -- AT announces changes automatically
text("save_status", "#{model.saved_count} items saved", a11y: %A11y{live: :polite})
# Decorative elements hidden from AT
rule(a11y: %A11y{hidden: true})
image("divider", "/images/decorative-line.png", a11y: %A11y{hidden: true})
# Disclosure / expandable sections
container "details", a11y: %A11y{expanded: model.expanded, role: :group, label: "Advanced options"} do
if model.expanded do
# ...
end
end
# Required form fields
text_input("email", model.email, a11y: %A11y{required: true, label: "Email address"})
```
Bare maps also work (normalized via `A11y.cast/1`):
```elixir
# These are equivalent:
button("close", "X", a11y: %A11y{label: "Close dialog"})
button("close", "X", a11y: %{label: "Close dialog"})
```
With the typed widget builder API (`Toddy.Iced.Widget.*`):
```elixir
alias Toddy.Iced.A11y
alias Toddy.Iced.Widget.{Button, Text, TextInput}
Button.new("close", "X")
|> Button.a11y(%A11y{label: "Close dialog"})
|> Button.build()
Text.new("Welcome")
|> Text.a11y(%A11y{role: :heading, level: 1})
|> Text.build()
TextInput.new("email", model.email)
|> TextInput.a11y(%A11y{required: true, label: "Email address"})
|> TextInput.build()
```
### Available roles
The `role` field accepts atoms. Use them to override the auto-inferred role
when a widget is semantically different from its type (e.g. a `text` that's
actually a heading, or a `container` that's a navigation landmark).
**Interactive:**
`:button`, `:checkbox` / `:check_box`, `:combo_box` / `:combobox`,
`:link`, `:menu_item`, `:radio` / `:radio_button`, `:slider`,
`:switch`, `:tab`, `:text_input`, `:multiline_text_input` /
`:text_editor`, `:tree_item`
**Structure:**
`:generic_container` / `:generic` / `:container`, `:group`,
`:heading`, `:label`, `:list`, `:list_item`, `:row`,
`:cell`, `:column_header`, `:row_header`, `:table`, `:tree`
**Landmarks:**
`:navigation`, `:region`, `:search`
**Status:**
`:alert`, `:alert_dialog` / `:alertdialog`, `:dialog`, `:status`,
`:timer`, `:meter`, `:progress_indicator` / `:progressbar`
**Other:**
`:document`, `:image`, `:menu`, `:menu_bar`, `:scroll_view`,
`:separator`, `:tab_list`, `:tab_panel`, `:toolbar`, `:tooltip`,
`:window`
Unknown role atoms are accepted but mapped to `Unknown`.
## Patterns and best practices
### Every interactive widget needs a name
Screen readers announce a widget's role and its label. A button with no
label is announced as just "button" -- useless. Make sure every button,
input, checkbox, and toggle has either:
- A visible label prop that auto-inference picks up, or
- An `a11y: %A11y{label: "..."}` override
```elixir
# Good -- label is auto-inferred from the button's label prop
button("save", "Save document")
# Good -- terse label with explicit a11y override for clarity
button("close", "X", a11y: %A11y{label: "Close dialog"})
# Bad -- screen reader just announces "button" with no name
button("do_thing", "")
```
### Use headings to create structure
Screen reader users navigate by headings. Use the `a11y` prop to mark
section titles:
```elixir
def view(model) do
window "main", title: "MyApp" do
column do
text("page_title", "Dashboard", a11y: %A11y{role: :heading, level: 1})
text("h_recent", "Recent activity", a11y: %A11y{role: :heading, level: 2})
# ... activity list ...
text("h_actions", "Quick actions", a11y: %A11y{role: :heading, level: 2})
# ... action buttons ...
end
end
end
```
### Use landmarks for page regions
Landmarks let screen reader users jump between major sections. Wrap
significant regions in containers with landmark roles:
```elixir
column do
container "nav", a11y: %A11y{role: :navigation, label: "Main navigation"} do
row do
button("home", "Home")
button("settings", "Settings")
button("help", "Help")
end
end
container "main_content", a11y: %A11y{role: :region, label: "Main content"} do
# ...
end
container "search_area", a11y: %A11y{role: :search, label: "Search"} do
text_input("query", model.query, placeholder: "Search...")
button("go", "Search")
end
end
```
### Live regions for dynamic content
When content changes and you want the screen reader to announce it
without the user navigating to it, use live regions:
- `:polite` -- announced after the current speech finishes (status
messages, save confirmations, non-urgent updates)
- `:assertive` -- interrupts current speech (errors, urgent alerts)
```elixir
# Status bar that announces changes
text("status", model.status_message, a11y: %A11y{live: :polite})
# Error message that interrupts
if model.error do
text("error", model.error,
a11y: %A11y{live: :assertive, role: :alert}
)
end
# Counter value announced on change
text("counter", "Count: #{model.count}", a11y: %A11y{live: :polite})
```
**Tip:** Only mark the element that changes as live, not its parent
container. Marking a large container as live causes the entire container's
text to be re-announced on every change.
### Forms
Label your inputs, mark required fields, and provide clear error feedback:
```elixir
column spacing: 12 do
text("form_heading", "Create account", a11y: %A11y{role: :heading, level: 1})
column spacing: 4 do
text("Username")
text_input("username", model.username,
a11y: %A11y{required: true, label: "Username"}
)
end
column spacing: 4 do
text("Email")
text_input("email", model.email,
a11y: %A11y{required: true, label: "Email address"}
)
if model.email_error do
text("email_error", model.email_error,
a11y: %A11y{live: :assertive, role: :alert}
)
end
end
button("submit", "Create account")
end
```
**Why the explicit `a11y: %A11y{label: "Username"}` when there's a visible
`text("Username")` above?** Because toddy doesn't automatically associate
a text label with the input below it. The visible text and the input are
separate widgets in the tree. The `a11y` label connects them for AT users.
#### Cross-widget relationships
Instead of duplicating label text in the `a11y` prop, you can point to
another widget by ID using `labelled_by`, `described_by`, and
`error_message`. The renderer resolves these to accesskit node
references so the screen reader follows the relationship automatically.
```elixir
column spacing: 12 do
text("form_heading", "Create account", a11y: %A11y{role: :heading, level: 1})
column spacing: 4 do
text("email-label", "Email")
text("email-help", "We'll send a confirmation link")
text_input("email", model.email,
a11y: %A11y{
labelled_by: "email-label",
described_by: "email-help",
error_message: "email-error"
}
)
if model.email_error do
text("email-error", model.email_error,
a11y: %A11y{role: :alert, live: :assertive}
)
end
end
button("submit", "Create account")
end
```
When the user focuses the email input, the screen reader announces the
label text from the `email-label` widget and the description from
`email-help`. If the field is invalid, it also announces the error text
from `email-error`.
Use `labelled_by` instead of `label` when a visible text widget already
provides the label -- it avoids duplicating the string and keeps the
label in sync if you change the visible text.
### Hiding decorative content
Decorative elements that add no information should be hidden from AT:
```elixir
# Decorative dividers
rule(a11y: %A11y{hidden: true})
# Decorative images
image("hero", "/images/banner.png", a11y: %A11y{hidden: true})
# Spacing elements
space(a11y: %A11y{hidden: true})
```
Don't hide functional elements. If an image conveys information, give it
an `alt` prop instead:
```elixir
image("status_icon", icon_path, alt: "Status: online")
```
### Canvas widgets
Canvas draws arbitrary shapes -- accesskit can't infer anything from raw
geometry. Always provide alternative text:
```elixir
# Static chart -- describe the content
canvas("chart",
layers: %{"data" => chart_shapes},
a11y: %A11y{role: :image, label: "Sales chart: Q1 revenue up 15%, Q2 flat"}
)
# Interactive canvas -- describe the interaction model
canvas("drawing",
layers: %{"shapes" => shapes},
a11y: %A11y{role: :image, label: "Drawing canvas, #{length(shapes)} shapes"}
)
```
For complex interactive canvases, consider whether the canvas is the right
choice for AT users, or whether an alternative text-based representation
would work better.
### Custom widgets with state
When building custom widgets with canvas or other primitives, use `toggled`,
`selected`, `value`, and `orientation` to expose their state to AT users.
Without these, screen readers have no way to know the state of a custom
control drawn with raw shapes.
```elixir
# Custom toggle switch built with canvas
canvas("dark-mode-switch", layers: [...],
a11y: %A11y{
role: :switch,
label: "Dark mode",
toggled: model.dark_mode
})
# Custom gauge showing percentage
canvas("cpu-gauge", layers: [...],
a11y: %A11y{
role: :meter,
label: "CPU usage",
value: "#{model.cpu_percent}%",
orientation: :horizontal
})
```
`toggled` and `selected` are booleans. Use `toggled` for on/off controls
(switches, checkboxes) and `selected` for selection state (list items, tabs).
`value` is a string describing the current value in human-readable form.
`orientation` tells AT users whether a control is horizontal or vertical,
which affects how they navigate it.
### Expanded/collapsed state
For disclosure widgets, toggleable panels, and dropdown menus:
```elixir
def view(model) do
column do
button("toggle_details",
if(model.show_details, do: "Hide details", else: "Show details"),
a11y: %A11y{expanded: model.show_details}
)
if model.show_details do
container "details", a11y: %A11y{role: :region, label: "Details"} do
# detail content
end
end
end
end
```
The `expanded` field tells AT whether the control is currently
expanded or collapsed, so screen readers can announce "Show details,
button, collapsed" or "Hide details, button, expanded".
## Action handling
When an AT triggers an action, iced translates it to a native event. The
renderer maps it to a standard toddy event:
| AT action | Toddy event | Notes |
|---|---|---|
| Click | `%Widget{type: :click, id: id}` | Screen reader activate, switch press |
| SetValue | `%Widget{type: :input, id: id, value: val}` | AT sets an input value directly |
| Focus | (internal) | Focus tracking, no event emitted |
| Other | `{:a11y_action, id, action_name}` | Scroll, dismiss, etc. |
Your `update/2` already handles `%Widget{type: :click, ...}` and `%Widget{type: :input, ...}` --
AT actions produce identical events. The `{:a11y_action, ...}` event is
a catch-all for actions without a direct widget equivalent:
```elixir
def update(model, {:a11y_action, _id, "scroll_down"}), do: scroll(model, :down)
def update(model, {:a11y_action, _id, "dismiss"}), do: close_dialog(model)
def update(model, {:a11y_action, _id, _action}), do: model
```
## Testing accessibility
The test framework provides assertions for verifying accessibility
semantics without running a screen reader.
### assert_role
Checks the inferred role for an element. This mirrors the role mapping,
so it catches mismatches between your widget type and the intended role:
```elixir
use Toddy.Test.Case, app: MyApp
test "heading has correct role" do
assert_role("#page_title", "heading")
end
test "nav container is a navigation landmark" do
assert_role("#nav", "navigation")
end
```
`assert_role` accounts for `a11y` role overrides -- if the element has
`a11y: %A11y{role: :heading}`, that takes precedence over the widget type.
### assert_a11y
Checks specific fields in the `a11y` prop:
```elixir
test "email field is required and labelled" do
assert_a11y("#email", %{"required" => true, "label" => "Email address"})
end
test "status has live region" do
assert_a11y("#status", %{"live" => "polite"})
end
test "decorative image is hidden" do
assert_a11y("#hero_image", %{"hidden" => true})
end
```
Note: `assert_a11y` checks the raw `a11y` prop on the element -- it
doesn't verify auto-inferred values (those come from iced's `Accessible`
trait). If the element has no `a11y` prop set, the assertion fails with a
clear message.
### Element helpers
`Toddy.Test.Element` provides lower-level accessors:
```elixir
test "element accessors" do
el = find!("#heading")
# Get the raw a11y prop map
assert %{"role" => "heading", "level" => 1} = Element.a11y(el)
# Get the inferred role (checks a11y override, then widget type)
assert Element.inferred_role(el) == "heading"
end
```
### Testing patterns
**Test the semantics, not the implementation.** Focus on what AT users
experience:
```elixir
test "todo app is accessible" do
# Headings provide structure
assert_role("#title", "heading")
# Interactive widgets are labelled
assert_a11y("#new_todo", %{"label" => "New todo"})
# Status updates are announced
type_text("#new_todo", "Buy milk")
submit("#new_todo")
assert_a11y("#todo_count", %{"live" => "polite"})
# Form validation errors are assertive
submit("#new_todo") # empty submit
assert_a11y("#error", %{"live" => "assertive"})
end
```
## Building
Accessibility is included by default in both precompiled binaries
(`mix toddy.download`) and source builds (`mix toddy.build`).
The renderer uses an iced fork (`v0.14.0-a11y-accesskit` branch) that adds
native accessibility support. The fork is referenced via `[patch.crates-io]`
in the renderer's `Cargo.toml`. No vendored crates or local path overrides
are needed.
Accessibility support is provided by:
| Component | What it provides |
|---|---|
| toddy-iced fork | accesskit + accesskit_winit, TreeBuilder, per-window adapter management |
| `toddy-core` | `A11yOverride` wrapper widget, `HiddenInterceptor`, AT action handling |
## Platform support
| Platform | AT | API | Status |
|---|---|---|---|
| Linux | Orca | AT-SPI2 | Supported |
| macOS | VoiceOver | NSAccessibility | Supported |
| Windows | NVDA, JAWS, Narrator | UI Automation | Supported |
All three platforms are supported via accesskit. The iced fork's a11y
integration creates platform adapters via accesskit_winit.
## Testing with a screen reader
To manually verify accessibility with a real screen reader:
### Linux (Orca)
```bash
# Build the renderer (a11y is included by default)
mix toddy.build
# Start Orca (usually Super+Alt+S, or from accessibility settings)
orca &
# Run your app
mix toddy.gui MyApp
```
Orca should announce widget roles and labels as you navigate with Tab.
Activate buttons with Enter or Space.
### macOS (VoiceOver)
```bash
# Build the renderer (a11y is included by default)
mix toddy.build
# Toggle VoiceOver: Cmd+F5
# Run your app
mix toddy.gui MyApp
```
Use VoiceOver keys (Ctrl+Option + arrow keys) to navigate. VoiceOver
should announce each widget's role and label.
### Windows (NVDA)
```bash
# Build the renderer (a11y is included by default)
mix toddy.build
# Start NVDA
# Run your app
mix toddy.gui MyApp
```
Tab between widgets. NVDA should announce roles, labels, and state
(checked, disabled, expanded, etc.).
## Architecture details
For contributors working on the accessibility internals:
### iced fork (`v0.14.0-a11y-accesskit` branch)
The iced fork adds native accessibility support. Key additions:
- **`Accessible` trait** -- widgets implement this to report their role,
label, and state to accesskit. Most built-in widgets already implement it.
- **`TreeBuilder`** in `iced_winit` -- walks the widget tree via `operate()`,
collecting `Accessible` metadata and building an accesskit `TreeUpdate`.
- **Per-window adapters** -- each window gets an accesskit adapter connecting
to the platform's AT layer.
- **AT action routing** -- AT actions are translated to native iced events,
which the renderer maps to toddy wire events.
The fork is referenced via `[patch.crates-io]` in the renderer's
`Cargo.toml`.
### A11yOverride wrapper widget
`a11y_widget.rs` in toddy contains two wrapper widgets:
- **`A11yOverride`** -- wraps any iced `Element` and intercepts `operate()`
to apply Elixir-side overrides from the `a11y` prop (role, label,
description, live, expanded, required, level, busy, invalid, modal,
read_only, mnemonic, toggled, selected, value, orientation, labelled_by,
described_by, error_message).
- **`HiddenInterceptor`** -- wraps an `Element` and suppresses it from the
accessibility tree when `hidden: true` is set.
These wrappers are applied automatically by the renderer when building the
iced widget tree from toddy's UI tree. No manual wrapping is needed from
Elixir.
### Renderer integration
When the renderer builds the iced widget tree from a toddy snapshot or
patch, it checks each node's `a11y` prop. If present (and not just
`hidden: true`), the rendered widget is wrapped in `A11yOverride`. If
`hidden: true`, it's wrapped in `HiddenInterceptor`. Nodes without an
`a11y` prop are rendered as-is -- iced's native `Accessible` trait provides
their baseline accessibility semantics.