Skip to main content

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}