Accessibility
The accessibility tree, every a11y attribute, the Role vocabulary, what the framework derives on its own, and how to test it.
Some people cannot see the screen, cannot use a pointer, or cannot read small text. They operate the same application through a program that reads it out, magnifies it, or drives it from a keyboard. This page explains what that program is told, what zgui works out on its own, and what you have to declare. It assumes Elements, Events and Attributes.
What an accessibility tree is
An assistive technology is a program outside your application that presents the interface in another form. A screen reader speaks it. A braille display prints it. A magnifier follows the caret. An automation script clicks through it.
None of them can read your pixels. A rectangle of colour does not say "this is a switch, it is called Wi-Fi, it is on, and pressing Space turns it off". So the operating system defines a second description of every window: a tree of nodes, each carrying
| Part | Example |
|---|---|
| a role — what kind of thing it is | button, switch, list item, heading |
| a name — what it is called | "Delete", "Wi-Fi" |
| state — what it is doing right now | on, expanded, disabled, invalid |
| bounds — where it is on the screen | a rectangle in CSS pixels |
| actions — what it can be asked to do | click it, focus it, increment it |
| relations — which other nodes it points at | "the text that names me is over there" |
That tree is the accessibility tree. It is not your document. It is a projection of it: layout boxes disappear, text nodes become names, and a control that is off the screen is marked absent.
zgui builds this tree from the document after every frame that changed anything, and hands it to AccessKit. AccessKit is the library that speaks each operating system's own protocol — UI Automation on Windows, NSAccessibility on macOS, AT-SPI on Linux — so the framework produces one description and AccessKit answers three platforms with it.
AccessKit is not hidden behind a wrapper. Role, Toggled, Live, NodeId and the rest are
AccessKit's own types, re-exported at zgui::vocab. A parallel copy would have to be kept in step
by hand and would convert on every property of every node.
The smallest accessible control
use zgui::prelude::*;
let count = RwSignal::new(0);
view! {
control(
a11y:role = Role::Button,
on:click = move |_| count.update(|n| *n += 1)
) {
"Add one"
}
}Three things are true of it already.
- The role is declared. Without
a11y:rolethe element isRole::GenericContainer, which consumers drop from the tree they present. - The name is derived. The element's own text child,
"Add one", becomes its accessible name. - The action is derived. The element has a
clicklistener, so the node advertisesAction::Click. A screen reader activating it produces a realclickon the same path a pointer takes, reaching the same listener.
control is also focusable by nature, so the keyboard reaches it by tabbing and Enter or Space on it
produces the same click. Pointer, keyboard and assistive technology all arrive at one on:click.
What is derived and what you declare
The framework works out everything it can see for itself. It never guesses at meaning.
Derived, with no declaration
| Derived | From |
|---|---|
| the accessible name | the element's own text children, joined with single spaces |
| bounds | the union of the element's fragments, resolved through the drawn frame's transforms, in CSS pixels |
| hidden | the element and everything below it generated no box |
| clips children | a fragment of the element clips its content |
| children | document child order, minus markers and minus text nodes |
Action::Click | the element has a click listener |
Action::Focus, Action::Blur | the element is focusable; Blur only while it holds focus |
Action::Increment, Action::Decrement | the element declared any numeric value, range or step |
Action::SetValue | the element declared a value, or has a change listener |
| focus | the window's focused node, reported on every update |
| the root transform | the window's scale factor |
Declared, or absent
Everything else. The role, description, placeholder, role_description, state_description,
keyboard_shortcut, tooltip, every flag, expanded, selected, toggled, invalid, live,
orientation, has_popup, auto_complete, sort_direction, current, the numeric range and
step, the set position, the table position, and every relation.
state: and a11y: are separate namespaces with separate destinations. state:checked writes the
UI state that CSS matches with :checked; a11y:toggled_on writes the accessibility property a
screen reader announces. Neither implies the other. A toggle that changes colour and says nothing
is a toggle that declared one of the two.
Every a11y: attribute
One namespace, 38 properties plus the role. Every one may be reactive: a constant, a signal, or a closure.
The role
| Attribute | Type | Says |
|---|---|---|
a11y:role | Role | what kind of thing the element is |
a11y:role may be written once on a node; writing it twice is the compile error
`a11y:role` is written once.
Naming and text
| Attribute | Type | Says |
|---|---|---|
a11y:label | SharedString | what this element is called |
a11y:description | SharedString | a longer description of this element |
a11y:value | SharedString | this control's value, as text |
a11y:placeholder | SharedString | the text shown when this field is empty |
a11y:role_description | SharedString | what this element's role is called, in words |
a11y:state_description | SharedString | what this control's value means, in words |
a11y:keyboard_shortcut | SharedString | the keystroke that activates this element |
a11y:tooltip | SharedString | the tooltip shown for this element |
Conditions
| Attribute | Type | Says |
|---|---|---|
a11y:disabled | bool | cannot be interacted with |
a11y:read_only | bool | the value cannot be changed |
a11y:required | bool | must have a value |
a11y:modal | bool | takes the interaction over |
a11y:busy | bool | still loading |
a11y:hidden | bool | absent from the presented tree, with everything inside it |
a11y:expanded | bool | this element's content is showing |
a11y:selected | bool | this element is selected |
a11y:toggled_on | bool | this toggle is on |
expanded and selected are three-valued underneath. Saying nothing means "not expandable at all";
saying false means "collapsed, and there is a control for it". The same distinction applies to
toggled_on.
Closed vocabularies
| Attribute | Type | Values |
|---|---|---|
a11y:toggled | Toggled | False, True, Mixed |
a11y:invalid | Invalid | True, Grammar, Spelling |
a11y:live | Live | Off, Polite, Assertive |
a11y:orientation | Orientation | Horizontal, Vertical |
a11y:has_popup | HasPopup | Menu, Listbox, Tree, Grid, Dialog |
a11y:sort_direction | SortDirection | Ascending, Descending, Other |
a11y:current | AriaCurrent | False, True, Page, Step, Location, Date, Time |
a11y:auto_complete | AutoComplete | Inline, List, Both |
All eight live at zgui::vocab. a11y:toggled is the three-state form of a11y:toggled_on: a
checkbox summarising a partly selected group is Toggled::Mixed, and a consumer announces it
differently from either of the other two.
Numbers
| Attribute | Type | Says |
|---|---|---|
a11y:numeric_value | f64 | this control's value, as a number |
a11y:level | usize | this heading's level, counting from one |
a11y:row_index | usize | which row of its table, counting from zero |
a11y:column_index | usize | which column of its table, counting from zero |
a11y:row_span | usize | how many rows this cell covers |
a11y:column_span | usize | how many columns this cell covers |
Relations
| Attribute | Type | Says |
|---|---|---|
a11y:labelled_by | NodeId | the element whose text names this one |
a11y:described_by | NodeId | the element whose text describes this one |
a11y:controls | NodeId | the element this one controls |
a11y:owns | NodeId | the element this one owns, which is not its child in the tree |
a11y:active_descendant | NodeId | the descendant that behaves as though it had focus |
a11y:popup_for | NodeId | the element this popup belongs to |
a11y:error_message | NodeId | the element holding this control's error message |
Rules that apply to all of them
- Every property may be reactive. They are all resolved inside one binding on the element, so a change re-lowers the whole description and re-projects one node.
- A reactive text property must produce a
SharedString. A literal&strconverts on its own; a closure has to saymove || SharedString::from(format!("…")). - Writing the same name twice is allowed for everything except
role. Both steps run and the later one wins. The list relations rely on this to accumulate targets. - With no role, the description says nothing about what the element is. That is not the same as saying it is a box. On a component call the caller's description merges over the component's own, and a role invented for the caller would silently replace the component's.
// The caller adds a label. The component's Role::Button survives.
view! {
SaveButton(a11y:label = "Save and close")
}What is not writable as an attribute
| Property | Why | Instead |
|---|---|---|
table_size(rows, columns) | takes two arguments, and an attribute passes one | A11yBinding::table_size(rows, columns) |
radio_group, numeric_range, numeric_step, set_position, clips_children, and the raw flag | no method on the reactive builder | A11yBinding::step |
Both escape hatches go through an attribute bundle, which a view spreads with {..}:
let semantics = A11yBinding::new(Role::Slider)
.numeric_value(move || value.get())
.step(|a11y| a11y.numeric_range(0.0, 100.0).numeric_step(5.0));
let own = Attrs::new().a11y_from(semantics);
view! {
control(class = "slider", tabindex = Focus::Sequential, {..own})
}A11yBinding::step takes impl Fn(A11y) -> A11y. The closure runs inside the binding's effect, so
anything reactive it reads is tracked like any other property.
Semantics::access_key and the coarse numeric jump are projected but have no builder method at
all today. Partial· Reachable only by constructing a Semantics value directly.
The role vocabulary
The role is the most important thing an element declares. It decides how a consumer announces the element, which keyboard conventions apply to it, and which of the other properties mean anything — a checked state means something on a checkbox and nothing on a heading.
Two roles have framework-wide meaning:
Role::GenericContaineris a box that exists for layout only. It is the default for every element that declares no role, and consumers filter it out of the tree they present, so deep visual nesting does not become deep spoken nesting.Role::TextRunis a run of text inside an editable field, and is likewise filtered out.
The roles you reach for most:
| Role | For |
|---|---|
Button | anything that does something when it is activated |
Switch | on or off, taking effect at once |
CheckBox | a fact something else will act on later; may be Mixed |
RadioButton | one of a group, with RadioGroup around them |
Link | navigation |
Heading with a11y:level | a section title |
TextInput, MultilineTextInput, SearchInput, PasswordInput | typed text |
Slider, SpinButton, ProgressIndicator, Meter | a number |
Tab, TabList, TabPanel | tabs |
Menu, MenuBar, MenuItem, MenuItemCheckBox, MenuItemRadio | menus |
ListBox, ListBoxOption, ComboBox | a chooser |
List, ListItem, Tree, TreeItem | a collection |
Table, Row, Cell, RowHeader, ColumnHeader, Grid, GridCell | tabular data |
Dialog, AlertDialog with a11y:modal | a window inside the window |
Alert, Status, Log | something that appeared and should be read out |
Group, Region, Section, Toolbar, Navigation, Main | structure |
Unknown TextRun Cell Label Image Link Row ListItem ListMarker TreeItem
ListBoxOption MenuItem MenuListOption Paragraph GenericContainer CheckBox
RadioButton TextInput Button DefaultButton Pane RowHeader ColumnHeader RowGroup
List Table LayoutTableCell LayoutTableRow LayoutTable Switch Menu
MultilineTextInput SearchInput DateInput DateTimeInput WeekInput MonthInput
TimeInput EmailInput NumberInput PasswordInput PhoneNumberInput UrlInput Abbr
Alert AlertDialog Application Article Audio Banner Blockquote Canvas Caption
Caret Code ColorWell ComboBox EditableComboBox Complementary Comment
ContentDeletion ContentInsertion ContentInfo Definition DescriptionList Details
Dialog DisclosureTriangle Document EmbeddedObject Emphasis Feed FigureCaption
Figure Footer Form Grid GridCell Group Header Heading Iframe
IframePresentational ImeCandidate Keyboard Legend LineBreak ListBox Log Main
Mark Marquee Math MenuBar MenuItemCheckBox MenuItemRadio MenuListPopup Meter
Navigation Note PluginObject ProgressIndicator RadioGroup Region RootWebArea
Ruby RubyAnnotation ScrollBar ScrollView Search Section SectionFooter
SectionHeader Slider SpinButton Splitter Status Strong Suggestion SvgRoot Tab
TabList TabPanel Term Time Timer TitleBar Toolbar Tooltip Tree TreeGrid Video
WebView Window PdfActionableHighlight PdfRoot GraphicsDocument GraphicsObject
GraphicsSymbol DocAbstract DocAcknowledgements DocAfterword DocAppendix
DocBackLink DocBiblioEntry DocBibliography DocBiblioRef DocChapter DocColophon
DocConclusion DocCover DocCredit DocCredits DocDedication DocEndnote DocEndnotes
DocEpigraph DocEpilogue DocErrata DocExample DocFootnote DocForeword DocGlossary
DocGlossRef DocIndex DocIntroduction DocNoteRef DocNotice DocPageBreak
DocPageFooter DocPageHeader DocPageList DocPart DocPreface DocPrologue
DocPullquote DocQna DocSubtitle DocTip DocToc ListGrid TerminalRole::Window is used by the framework for the root node of each window. Role::Unknown is the
enumeration's own default and is never what an undeclared element gets — that is
GenericContainer.
Names
An element gets its name from one of three places, in this order.
A declared a11y:label. It wins over everything.
A labelled_by relation. The consumer reads the target node's name.
The element's own text children, joined with single spaces.
The third rule is the one that surprises people, because only the element's own text children count.
// Named "Delete": the string is a text child of the control.
control(a11y:role = Role::Button) {"Delete"}
// The control has no name. "Delete" landed on the inner element instead.
control(a11y:role = Role::Button) { text {"Delete"} }A name assembled from a whole subtree would make one deep element pay for every text node beneath it, and the change key the projection is scheduled by hashes exactly these children. When the words are nested, declare the label or point a relation at them.
Four roles put their own text in value rather than in label: Label, TextInput,
MultilineTextInput and SearchInput. A consumer reads a Label node's text from its value, and
an editable element's text is its value — the document is the only copy of it, so the element
declares no value and the projection reads the real one.
a11y:description is the longer second string a consumer reads after the name. Use it for the hint
under a field, not for a second name.
Relations
A relation names another node. The type is NodeId, but you never write one: a
NodeRef converts directly.
let caption = NodeRef::new();
let hint = NodeRef::new();
view! {
column {
label(node_ref = caption) {"Full name"}
field(
a11y:role = Role::TextInput,
a11y:labelled_by = caption,
a11y:described_by = hint,
a11y:required = true
)
text(node_ref = hint) {"As it appears on your passport"}
}
}The conversion tracks. A relation written before its target mounts resolves to nothing on that frame, and is filled in on the frame the target appears. It empties again when the target goes away.
When the target itself changes, write a closure returning Option<NodeRef>:
let hint = NodeRef::new();
let error = NodeRef::new();
field(a11y:described_by = move || Some(if wrong.get() { error } else { hint }))None names nothing, and names it the same way an unbound handle does.
Every target is filtered against the projected tree before it is written. A relation naming a node that is not there is dropped rather than published. This is not tidiness: a consumer resolves a relation with an unchecked lookup, on a thread this process does not own and cannot catch. A debug build asserts that no published update names anything unresolvable.
The eight relations are labelled_by, described_by, controls, owns, radio_group,
active_descendant, popup_for and error_message. Seven are writable as attributes;
radio_group goes through A11yBinding::step.
Two shapes are worth knowing. A trigger that opened a dialog the framework moved to an overlay band projects to this:
Button label="Delete" expanded=true has_popup=Dialog owns=[dialog]
Dialog modal labelled_by=[title] described_by=[body] popup_for=buttonowns re-parents the portalled surface for a consumer: the dialog is a child of the overlay band in
the document, and a child of the trigger in the tree that is read out.
A combobox whose keyboard focus never leaves the input projects to this:
ComboBox label="Fruit" expanded=true has_popup=Listbox controls=[list]
owns=[list] active_descendant=optionactive_descendant is what lets an arrow key walk the list while focus stays in the text field.
Live regions and announcements
There is no announcement queue and no announce() function. A live region is an element whose
changes a consumer reads out without being asked, and it is declared with one attribute.
use zgui::vocab::Live;
view! {
text(class = "status", a11y:live = Live::Polite) {
{move || format!("{} results", results.get().len())}
}
}| Value | Behaviour |
|---|---|
Live::Off | not a live region |
Live::Polite | read out when the consumer next pauses |
Live::Assertive | interrupt whatever is being read |
The mechanism is ordinary. The text changes, the node is marked, the node is re-projected, the changed node is in the next update, and the consumer sees a live region change. Nothing is queued and nothing is timed.
Two additions:
SemanticFlags::LIVE_ATOMICsays "announce the whole region, not the part that changed". It has no attribute; reach it with.step(|a| a.flag(SemanticFlags::LIVE_ATOMIC, true)).Role::AlertandRole::Statusare roles a consumer already treats as live. A toast wantsRole::Alert; a quiet counter wantsLive::Politeon a plain element.
Use Assertive for one thing only: something the user must act on now. Everything else is Polite.
Focus and keyboard reachability
An interface nobody can reach with a keyboard is inaccessible whatever its tree says. Focus is an accessibility concern for a second reason as well: focus is a field of every accessibility update, so a frame that moved focus and changed nothing else still publishes one.
There is exactly one answer to "which node can take focus", shared by the Tab key and by an assistive technology's focus request. Two answers would be two tab orders.
| Rule | Detail |
|---|---|
| focusable by nature | control, field and editor, with nothing declared |
| focusable by declaration | any element with tabindex = Focus::Sequential or Focus::Programmatic |
| never focusable | anything carrying state:disabled, or generating no visible box |
| sequential order | document order; Focus::Programmatic is reachable but not in the sequence |
// A row that is not a control, but has to be reachable.
row(class = "item", tabindex = Focus::Sequential, on:click = pick) { … }Enter and Space on the focused element produce a real click. So does an assistive technology's
activation request. One on:click handles a pointer, a keyboard and a screen reader, and there is
no separate accessibility activation path to write.
A control that is disabled while it holds focus has to leave the sequential order. Make tabindex
reactive when the control can be disabled:
tabindex = move || if disabled.get() { Focus::Programmatic } else { Focus::Sequential }.
Keyboard and focus covers traversal, :focus-visible, focus
traps and programmatic focus in full.
How the tree is published
publish_a11y is the last phase of a frame that has anything to say, and it runs after the
renderer. What a consumer is told about a node's position has to be what was drawn, not what was
about to be.
Geometry moves are noted. A node the frame carried somewhere else is named by the fragment pass. A node whose whole coordinate system was rewritten — an element under a running transform — was never touched at all, and is named by the space that moved.
The document's accessibility marks are drained, on every frame, listener or not. Invalidation is a union: a bit nobody retires keeps every ancestor's subtree marked for the life of the window, and every other stage would then descend everywhere. Draining is cheap; what it gathers accumulates.
Nothing owed and focus unmoved: the frame publishes nothing.
Only focus moved: an update carrying no nodes at all, which costs no projection.
Otherwise the surface is handed a closure, not a tree. AccessKit runs it only if something is listening. On a machine with no assistive technology running, the whole phase costs one comparison.
What marks a node for re-projection
| Change | Also marks |
|---|---|
writing any a11y: property | the node and its ancestors |
| changing a text node's text | the node's ancestors |
setting a prop: value | the node and its ancestors |
| adding or removing a listener | the node and its ancestors |
| creating or inserting an element | the node and its ancestors |
| removing a subtree | the node and its ancestors |
checked, disabled, open or indeterminate changing | the node and its ancestors |
What an update contains
An update is a difference. Each marked node is projected afresh — the projection is a pure function of the frame, nothing cached — and is put in the update only if the result differs from the last one sent for that node.
Two rules are not optional:
- A node's parent is re-projected whenever the node is. A child list belongs to the parent and to nothing else, so an appearance, a disappearance or an identity change is only visible once the parent is sent again.
- Departures are retired before anything is projected. A node that leaves invalidates every node naming it, and those nodes are usually not in this frame's marks at all.
Once more than 4096 nodes are owed, the next build is a whole tree instead, because at that size a rebuild is cheaper than a diff. A consumer that connects late is also sent a whole tree, although nothing is dirty — nothing has changed; what is missing is its copy.
What it costs
Nothing, when nothing is listening. The tree is built inside a closure the surface calls only when a consumer is active.
When something is listening, one projection per changed node, plus its parent. In the
maintainer's gallery workload, one window resize — the worst case, where every one of 2 265 boxes
moved and had to be re-measured — spent 0.58 ms in the accessibility phase, 2.6 % of an 18.4 ms
frame (f.a11y → f.recycle, docs/perf/gallery-interactions.md). An ordinary interaction changes
one or two nodes.
A ticking number is one node. A text node is not a node in this tree; the characters inside an
element are that element's name. label { {count} } is one node whose name changes.
Actions coming back
An assistive technology does not only read. It acts. Each request arrives as a wake, is matched to the window whose document holds the node, and is carried out inside that window's reactive scope.
| Requested | What happens |
|---|---|
Click | a synthesised click on the node, down the same capture, target and bubble path a pointer takes |
Focus / Blur | focus moves to the node, or away from it |
Increment / Decrement | a change event carrying the stepped value |
SetValue / ReplaceSelectedText | a change event carrying the new value |
ShowContextMenu | a context_menu event on the node |
ScrollIntoView | the same call NodeRef::scroll_to makes |
SetScrollOffset | the container scrolls to an absolute offset in CSS pixels |
ScrollUp / Down / Left / Right | one line of scrolling, as a wheel would have reported it |
Collapse, Expand, CustomAction, ShowTooltip, HideTooltip, ScrollToPoint, SetTextSelection | ignored |
A request naming a node this build cannot act on is left alone rather than absorbed. An assistive technology told an action succeeded when nothing happened tells its user the application responded.
The step size for Increment and Decrement is the declared numeric_step when it is above zero,
otherwise a hundredth of the declared range, otherwise one hundredth. The result is clamped to
whichever of the range ends were declared.
Testing accessibility
One component
zgui-testkit-view mounts a component with no window, no style engine and no layout, and lets you
read what it wrote. Add it to your [dev-dependencies]; it is not re-exported by zgui.
use zgui::prelude::*;
use zgui::vocab::{Role, Semantics, Toggled};
use zgui_testkit_view::Window;
#[test]
fn a_toggle_says_what_it_is_and_which_position_it_is_in() {
let window = Window::open();
let on = window.scope.with(|| RwSignal::new(false));
let mut built = window.scope.with(|| {
view! {
control(
a11y:role = Role::Switch,
a11y:label = "Dark mode",
a11y:toggled_on = move || on.get()
)
}
.into_view()
.build(&mut window.cx.cx())
});
built.mount(&window.dom_handle, window.root, None);
window.frame();
let node = window.dom.tree().children(window.root)[0];
let semantics: Semantics = window
.dom
.tree()
.semantics(node)
.expect("the control says what it is");
assert_eq!(semantics.role, Role::Switch);
assert_eq!(semantics.label.as_deref(), Some("Dark mode"));
assert_eq!(semantics.toggled, Some(Toggled::False));
on.set(true);
window.frame();
assert_eq!(
window.dom.tree().semantics(node).expect("still there").toggled,
Some(Toggled::True)
);
}Everything on Semantics is assertable this way: role, label, description, value, flags,
toggled, invalid, live, numeric, position, table and relations.
The same window keeps a transcript of every change made to the tree, one line each. A description appears as its role, and a cleared one says so:
semantics #1 Switch
semantics #1 clearedTranscript::assert_matches(path) compares the whole recorded sequence against a checked-in file,
which catches a description that stopped being written as well as one that changed.
window.frame() is what makes a written signal reach the tree. Asserting straight after an
interaction asserts on the frame before the one it caused.
A whole window
zgui-platform-headless runs the real frame loop over an offscreen surface and records every
accessibility update the surface was handed:
Method on OffscreenSurface | Answers |
|---|---|
last_a11y_update() | the most recent TreeUpdate |
a11y_log() | the whole sequence, in order |
a11y_updates() | how many were published |
The whole sequence is kept because an update is a difference: what it names resolves against everything sent before it.
zgui_a11y::dump(&update) turns one update into stable, sorted text — one line per node, with
identifiers rewritten as small ordinals — which is what a golden file compares against. A screenshot
cannot show a missing relation, and a combobox that stopped pointing at its active option looks
identical and is unusable.
tree root=#1
focus #1
#1 Window children=[#2]
#2 GenericContainer children=[#3 #4 #5]
#3 GenericContainer label="Full name"
#4 TextInput required labelled_by=[#3] described_by=[#5]
#5 GenericContainer label="As it appears on your passport"zgui_a11y::dangling(&update, retained) answers the other question: does every identifier this
update mentions resolve, either inside the update or inside what the consumer already holds. The
runtime runs it in a debug assertion around every published update.
Testing covers both harnesses in full.
A worked example: an accessible toggle
Everything above, in one component. No component library, no primitives — a control, a label and
a style sheet.
use zgui::prelude::*;
/// A switch with a caption beside it, operable by pointer, keyboard and screen reader.
#[component]
fn Toggle(
/// Whether it is on. Written back to when it is flipped.
on: RwSignal<bool>,
/// What it is called.
#[prop(into)]
caption: String,
/// Whether it can be operated.
#[prop(into, default = Signal::stored(false))]
disabled: Signal<bool>,
) -> impl IntoView {
let name = NodeRef::new();
view! {
row(class = "toggle") {
label(class = "toggle__caption", node_ref = name) {{caption}}
control(
class = "toggle__switch",
// What CSS matches.
state:checked = move || on.get(),
state:disabled = move || disabled.get(),
// What a screen reader is told. Separate, and both are needed.
a11y:role = Role::Switch,
a11y:labelled_by = name,
a11y:toggled_on = move || on.get(),
a11y:disabled = move || disabled.get(),
a11y:state_description = move || {
zgui::vocab::SharedString::from(if on.get() { "on" } else { "off" })
},
on:click = move |_| {
if !disabled.get_untracked() {
on.update(|value| *value = !*value);
}
}
) {
box(class = "toggle__thumb")
}
}
}
}
const SHEET: &str = css!(
".toggle {
align-items: center;
gap: 12px;
}
.toggle__switch {
width: 44px;
height: 24px;
padding: 2px;
border-radius: 999px;
background-color: #2b3243;
display: flex;
justify-content: flex-start;
}
.toggle__thumb {
width: 20px;
height: 20px;
border-radius: 999px;
background-color: #e8ecf4;
}
.toggle__switch:checked { background-color: #3b6cf6; justify-content: flex-end; }
.toggle__switch:focus-visible { outline: 2px solid #7aa2ff; outline-offset: 2px; }
.toggle__switch:disabled { opacity: 0.4; }"
);What each part earns:
| Written | Gives |
|---|---|
control | focusable by nature, so Tab reaches it and Enter or Space activates it |
a11y:role = Role::Switch | announced as a switch, with two positions and no mixed one |
a11y:labelled_by = name | named by the caption beside it, which is not its ancestor |
a11y:toggled_on | the position, announced on every change |
state:checked | the position, matched by :checked in the sheet |
a11y:disabled + state:disabled | announced as disabled, refuses pointer events, and drops out of the tab order |
a11y:state_description | "on" and "off" instead of the consumer's default wording |
on:click | one handler for the pointer, the keyboard and the accessibility action |
Nothing here declares bounds, children, actions or focus. All four are derived.
What is left to check by hand
- Contrast and size. The framework does not check either.
:focus-visiblegets an outline from the framework's own sheet; a sheet of yours that removes it removes the only sign of where the keyboard is. - Reading order. The tree follows document order. If the visual order differs from the source order, the two disagree.
- Screen coordinates on Wayland. The compositor does not report window position, so features that need "where is this on the screen" — a magnifier following the caret — are degraded there. Partial· A platform limitation, not a framework one.