a11y_kit/node.rs
1//! The semantic node model (a11y-kit, ADR-0004 §2): role, accessible name,
2//! states, and relations — platform-free, so it can be audited headlessly
3//! in CI and lowered into AccessKit (or anything else) at the edge.
4
5use core::fmt;
6
7/// A stable node identity within one tree.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub struct NodeId(pub u32);
10
11impl fmt::Display for NodeId {
12 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13 write!(f, "#{}", self.0)
14 }
15}
16
17/// The semantic roles the estate's controls map onto. Deliberately a
18/// toolkit-neutral subset of the ARIA/accesskit role vocabulary — the
19/// lowering adapters pick the framework's spelling.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum A11yRole {
23 /// Top-level window.
24 Window,
25 /// Generic container.
26 Group,
27 /// Section heading.
28 Heading,
29 /// Static text.
30 Paragraph,
31 /// Push button.
32 Button,
33 /// Toggle / tri-state checkbox.
34 Checkbox,
35 /// Single-line text entry.
36 Textbox,
37 /// Multi-line text entry.
38 MultilineTextbox,
39 /// Numeric slider.
40 Slider,
41 /// Dropdown / select.
42 ComboBox,
43 /// List container.
44 List,
45 /// List item.
46 ListItem,
47 /// Tab strip.
48 TabList,
49 /// One tab.
50 Tab,
51 /// Data table.
52 Table,
53 /// Table row.
54 Row,
55 /// Table cell.
56 Cell,
57 /// Toolbar.
58 Toolbar,
59 /// Menu container.
60 Menu,
61 /// Menu entry.
62 MenuItem,
63 /// Status/alert region (live region).
64 StatusBar,
65 /// Dialog.
66 Dialog,
67 /// Blocking alert.
68 Alert,
69 /// Progress indicator.
70 ProgressBar,
71 /// Link.
72 Link,
73 /// Separator.
74 Separator,
75 /// Image with alternatives.
76 Image,
77 /// Canvas/surface region.
78 Canvas,
79}
80
81/// True for roles a keyboard user must be able to reach and operate —
82/// roles whose accessible name the audit requires.
83#[must_use]
84pub const fn is_interactive(role: A11yRole) -> bool {
85 matches!(
86 role,
87 A11yRole::Button
88 | A11yRole::Checkbox
89 | A11yRole::Textbox
90 | A11yRole::MultilineTextbox
91 | A11yRole::Slider
92 | A11yRole::ComboBox
93 | A11yRole::ListItem
94 | A11yRole::Tab
95 | A11yRole::MenuItem
96 | A11yRole::Link
97 )
98}
99
100/// Tri-state checkbox value.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TriState {
103 /// Not checked.
104 Unchecked,
105 /// Checked.
106 Checked,
107 /// Indeterminate ("mixed").
108 Mixed,
109}
110
111/// Node state flags.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub struct States {
114 /// Disabled.
115 pub disabled: bool,
116 /// Focused (at most one per tree — audited).
117 pub focused: bool,
118 /// Selected.
119 pub selected: bool,
120 /// Expanded (collapsible containers).
121 pub expanded: bool,
122 /// Required (form input).
123 pub required: bool,
124 /// Invalid (form input with an error).
125 pub invalid: bool,
126 /// Hidden from the a11y tree (still present for focus management).
127 pub hidden: bool,
128 /// Modal (blocks sibling interaction).
129 pub modal: bool,
130 /// Checkbox value.
131 pub checked: Option<TriState>,
132 /// Busy: the value is transient or not yet settled.
133 ///
134 /// Two callers need this and neither had a way to say it (found by
135 /// composing `input-kit` against this crate, 2026-10-18):
136 ///
137 /// - **IME composition.** A textbox whose value is a half-formed syllable
138 /// must not have that value announced. `aria-busy` is the signal, and
139 /// `input-kit`'s `Composer::is_composing` is what drives it.
140 /// - **Async loading.** A textbox whose value is still being fetched should
141 /// not be announced as empty.
142 ///
143 /// `busy` is distinct from `disabled` and `invalid`: the control is live
144 /// and valid, only its value is not final.
145 pub busy: bool,
146}
147
148/// A relation between nodes (the edges a screen reader traverses).
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum Relation {
151 /// This node controls the target (e.g. a toggle controls a panel).
152 Controls(NodeId),
153 /// The target describes this node (an error message).
154 DescribedBy(NodeId),
155 /// The target names this node.
156 LabelledBy(NodeId),
157 /// The focused control's current suggestion (combobox autocomplete).
158 ActiveDescendant(NodeId),
159}
160
161/// One semantic node.
162#[derive(Debug, Clone, PartialEq)]
163pub struct A11yNode {
164 /// Identity.
165 pub id: NodeId,
166 /// Semantic role.
167 pub role: A11yRole,
168 /// Accessible name (required for interactive roles).
169 pub name: String,
170 /// Longer description.
171 pub description: Option<String>,
172 /// State flags.
173 pub states: States,
174 /// Structural parent (None for a root).
175 pub parent: Option<NodeId>,
176 /// Non-structural relations.
177 pub relations: Vec<Relation>,
178}
179
180impl A11yNode {
181 /// A node with the common fields; states/relations default empty.
182 #[must_use]
183 pub fn new(id: NodeId, role: A11yRole, name: impl Into<String>) -> Self {
184 Self {
185 id,
186 role,
187 name: name.into(),
188 description: None,
189 states: States::default(),
190 parent: None,
191 relations: Vec::new(),
192 }
193 }
194
195 /// Attach a description.
196 pub fn with_description(mut self, description: impl Into<String>) -> Self {
197 self.description = Some(description.into());
198 self
199 }
200
201 /// Set states.
202 #[must_use]
203 pub fn with_states(mut self, states: States) -> Self {
204 self.states = states;
205 self
206 }
207
208 /// Set the structural parent.
209 #[must_use]
210 pub fn with_parent(mut self, parent: NodeId) -> Self {
211 self.parent = Some(parent);
212 self
213 }
214
215 /// Add a relation.
216 pub fn with_relation(mut self, relation: Relation) -> Self {
217 self.relations.push(relation);
218 self
219 }
220}