rosace_core/semantic_node.rs
1/// The ARIA-style role of a semantic node in the accessibility tree.
2///
3/// This is the one source of truth for role data flowing through
4/// `RenderTree::collect_semantics()` — used both for assistive tech (D099)
5/// and, from D107/Phase 25 on, for mapping to real HTML tags (`<h1>`-`<h6>`,
6/// `<a>`, `<ul>`/`<li>`, ...) for SEO/crawler-facing output. Deliberately
7/// NOT unified with the separate, richer `rosace_a11y::role::Role` — that
8/// one drives `rosace-a11y`'s own internal focus-management tree, a
9/// different concern (focus navigation, not HTML/SEO structure); merging
10/// them would touch already-working, unrelated code for no benefit this
11/// phase actually needs. `Link`/`Heading`/`List`/`ListItem`/`Tab`/
12/// `TabPanel`/`Radio` added here specifically for the HTML mapping Phase 25
13/// needs (a heading's level and a link's href live on `SemanticNode`/
14/// `Semantics` directly, not on the enum, since they're per-instance data,
15/// not part of what kind of role it is). `Radio` is distinct from
16/// `Checkbox` — real ARIA/HTML (`role="radio"` vs `role="checkbox"`)
17/// distinguishes mutually-exclusive single-select from independent
18/// toggles; approximating one as the other would be wrong, not just
19/// imprecise, so it earns its own variant rather than reusing `Checkbox`.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum Role {
22 Button,
23 Text,
24 Image,
25 Slider,
26 Alert,
27 Dialog,
28 Checkbox,
29 Radio,
30 Switch,
31 TextInput,
32 MenuItem,
33 ProgressBar,
34 Link,
35 Heading,
36 List,
37 ListItem,
38 Tab,
39 TabPanel,
40 Unknown,
41}
42
43/// A node in the accessibility (semantics) tree.
44///
45/// The semantics tree mirrors the visual element tree but carries only the
46/// information assistive technologies need. It is rebuilt alongside the render
47/// tree and diffed separately.
48#[derive(Clone, Debug)]
49pub struct SemanticNode {
50 /// Human-readable label announced by screen readers.
51 pub label: Option<String>,
52 /// The ARIA role of this node.
53 pub role: Role,
54 /// The node's current value, if any (a `TextInput`'s typed text, a
55 /// `Slider`/`ProgressBar`'s numeric value as a string, ...) — distinct
56 /// from `label`, which is the node's accessible NAME, not its content.
57 pub value: Option<String>,
58 /// `1..=6` for `Role::Heading` (`<h1>`-`<h6>`); `None` for every other
59 /// role, including a heading whose level genuinely isn't known (falls
60 /// back to `<h2>` at the HTML-mapping step, not here).
61 pub heading_level: Option<u8>,
62 /// The link target for `Role::Link` (`<a href="...">`); `None` for
63 /// every other role.
64 pub href: Option<String>,
65 /// Child semantic nodes.
66 pub children: Vec<SemanticNode>,
67}
68
69impl SemanticNode {
70 /// Creates a new `SemanticNode` with no label, `Role::Unknown`, and no children.
71 pub fn new() -> Self {
72 SemanticNode {
73 label: None,
74 role: Role::Unknown,
75 value: None,
76 heading_level: None,
77 href: None,
78 children: Vec::new(),
79 }
80 }
81
82 /// Sets the accessible label for this node.
83 pub fn label(mut self, label: impl Into<String>) -> Self {
84 self.label = Some(label.into());
85 self
86 }
87
88 /// Sets the ARIA role for this node.
89 pub fn role(mut self, role: Role) -> Self {
90 self.role = role;
91 self
92 }
93
94 /// Sets the node's current value (see the field doc for how this
95 /// differs from `label`).
96 pub fn value(mut self, value: impl Into<String>) -> Self {
97 self.value = Some(value.into());
98 self
99 }
100
101 /// Sets the heading level (`1..=6`) — meaningful only for `Role::Heading`.
102 pub fn heading_level(mut self, level: u8) -> Self {
103 self.heading_level = Some(level);
104 self
105 }
106
107 /// Sets the link target — meaningful only for `Role::Link`.
108 pub fn href(mut self, href: impl Into<String>) -> Self {
109 self.href = Some(href.into());
110 self
111 }
112
113 /// Appends a child semantic node.
114 pub fn child(mut self, node: SemanticNode) -> Self {
115 self.children.push(node);
116 self
117 }
118}
119
120impl Default for SemanticNode {
121 fn default() -> Self {
122 SemanticNode::new()
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn new_semantic_node_has_unknown_role_and_no_optional_fields() {
132 let node = SemanticNode::new();
133 assert_eq!(node.role, Role::Unknown);
134 assert!(node.label.is_none());
135 assert!(node.value.is_none());
136 assert!(node.heading_level.is_none());
137 assert!(node.href.is_none());
138 assert!(node.children.is_empty());
139 }
140
141 #[test]
142 fn builder_methods_set_the_expected_fields() {
143 let node = SemanticNode::new()
144 .role(Role::Heading)
145 .label("Section title")
146 .heading_level(2)
147 .value("current value")
148 .child(SemanticNode::new().role(Role::Text).label("child"));
149 assert_eq!(node.role, Role::Heading);
150 assert_eq!(node.label.as_deref(), Some("Section title"));
151 assert_eq!(node.heading_level, Some(2));
152 assert_eq!(node.value.as_deref(), Some("current value"));
153 assert_eq!(node.children.len(), 1);
154 }
155
156 #[test]
157 fn href_only_meaningful_for_link_but_settable_regardless() {
158 let node = SemanticNode::new().role(Role::Link).href("https://example.com");
159 assert_eq!(node.href.as_deref(), Some("https://example.com"));
160 }
161
162 #[test]
163 fn default_matches_new() {
164 assert_eq!(SemanticNode::default().role, SemanticNode::new().role);
165 }
166}