Skip to main content

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}