Skip to main content

stratum_core/
aria.rs

1use serde::{Deserialize, Serialize};
2
3/// Complete set of ARIA attributes for a component.
4///
5/// Every interactive component in NexusStratum produces an `AriaAttributes`
6/// as part of its `RenderOutput`. Framework adapters apply these to the
7/// rendered DOM elements.
8///
9/// All fields are `Option` — only set attributes are rendered to the DOM.
10/// This prevents polluting the accessibility tree with unnecessary attributes.
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
12pub struct AriaAttributes {
13    /// The ARIA role of the element.
14    pub role: Option<AriaRole>,
15
16    /// A human-readable label for the element.
17    pub label: Option<String>,
18
19    /// ID of the element that labels this element.
20    pub labelledby: Option<String>,
21
22    /// ID of the element that describes this element.
23    pub describedby: Option<String>,
24
25    /// Whether a section is expanded or collapsed.
26    pub expanded: Option<bool>,
27
28    /// Whether an option is selected.
29    pub selected: Option<bool>,
30
31    /// Checked state (supports indeterminate via TriState).
32    pub checked: Option<TriState>,
33
34    /// Whether the element is disabled.
35    pub disabled: Option<bool>,
36
37    /// Whether the element is required.
38    pub required: Option<bool>,
39
40    /// Whether the element's value is invalid.
41    pub invalid: Option<bool>,
42
43    /// Live region politeness setting.
44    pub live: Option<AriaLive>,
45
46    /// Whether the entire live region should be announced.
47    pub atomic: Option<bool>,
48
49    /// ID of the element this element controls.
50    pub controls: Option<String>,
51
52    /// ID of elements owned by this element (not DOM children).
53    pub owns: Option<String>,
54
55    /// Type of popup triggered by this element.
56    pub haspopup: Option<AriaHasPopup>,
57
58    /// Heading level (1-6).
59    pub level: Option<u8>,
60
61    /// Orientation of the element (horizontal or vertical).
62    pub orientation: Option<Orientation>,
63
64    /// Whether the element is read-only.
65    pub readonly: Option<bool>,
66
67    /// Whether multiple items can be selected.
68    pub multiselectable: Option<bool>,
69
70    /// Minimum value for range widgets.
71    pub valuemin: Option<f64>,
72
73    /// Maximum value for range widgets.
74    pub valuemax: Option<f64>,
75
76    /// Current value for range widgets.
77    pub valuenow: Option<f64>,
78
79    /// Human-readable text alternative for the current value.
80    pub valuetext: Option<String>,
81
82    /// Whether the element is hidden from the accessibility tree.
83    pub hidden: Option<bool>,
84
85    /// ID of the element that is the active descendant.
86    pub activedescendant: Option<String>,
87
88    /// Whether the element is busy (loading).
89    pub busy: Option<bool>,
90
91    /// Current item in a set (e.g., "3 of 10").
92    pub posinset: Option<u32>,
93
94    /// Total number of items in a set.
95    pub setsize: Option<u32>,
96
97    /// Column count for tables/grids.
98    pub colcount: Option<u32>,
99
100    /// Column index for table cells.
101    pub colindex: Option<u32>,
102
103    /// Column span for table cells.
104    pub colspan: Option<u32>,
105
106    /// Row count for tables/grids.
107    pub rowcount: Option<u32>,
108
109    /// Row index for table rows.
110    pub rowindex: Option<u32>,
111
112    /// Row span for table cells.
113    pub rowspan: Option<u32>,
114
115    /// Sort direction for sortable columns.
116    pub sort: Option<AriaSort>,
117
118    /// Whether autocomplete is available.
119    pub autocomplete: Option<AriaAutocomplete>,
120
121    /// Current state for widgets with multiple states.
122    pub current: Option<AriaCurrent>,
123
124    /// ID of the error message element.
125    pub errormessage: Option<String>,
126
127    /// Keyboard shortcut.
128    pub keyshortcuts: Option<String>,
129
130    /// Roledescription override.
131    pub roledescription: Option<String>,
132
133    /// Whether the element is modal.
134    pub modal: Option<bool>,
135
136    /// Placeholder text.
137    pub placeholder: Option<String>,
138}
139
140impl AriaAttributes {
141    /// Create a new empty AriaAttributes.
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Set the role.
147    pub fn with_role(mut self, role: AriaRole) -> Self {
148        self.role = Some(role);
149        self
150    }
151
152    /// Set the label.
153    pub fn with_label(mut self, label: impl Into<String>) -> Self {
154        self.label = Some(label.into());
155        self
156    }
157
158    /// Set the labelledby reference.
159    pub fn with_labelledby(mut self, id: impl Into<String>) -> Self {
160        self.labelledby = Some(id.into());
161        self
162    }
163
164    /// Set the describedby reference.
165    pub fn with_describedby(mut self, id: impl Into<String>) -> Self {
166        self.describedby = Some(id.into());
167        self
168    }
169
170    /// Set the expanded state.
171    pub fn with_expanded(mut self, expanded: bool) -> Self {
172        self.expanded = Some(expanded);
173        self
174    }
175
176    /// Set the selected state.
177    pub fn with_selected(mut self, selected: bool) -> Self {
178        self.selected = Some(selected);
179        self
180    }
181
182    /// Set the checked state.
183    pub fn with_checked(mut self, checked: TriState) -> Self {
184        self.checked = Some(checked);
185        self
186    }
187
188    /// Set the disabled state.
189    pub fn with_disabled(mut self, disabled: bool) -> Self {
190        self.disabled = Some(disabled);
191        self
192    }
193
194    /// Set the controls reference.
195    pub fn with_controls(mut self, id: impl Into<String>) -> Self {
196        self.controls = Some(id.into());
197        self
198    }
199
200    /// Set the modal state.
201    pub fn with_modal(mut self, modal: bool) -> Self {
202        self.modal = Some(modal);
203        self
204    }
205
206    /// Set the haspopup type.
207    pub fn with_haspopup(mut self, popup: AriaHasPopup) -> Self {
208        self.haspopup = Some(popup);
209        self
210    }
211
212    /// Set the orientation.
213    pub fn with_orientation(mut self, orientation: Orientation) -> Self {
214        self.orientation = Some(orientation);
215        self
216    }
217
218    /// Collect all set attributes as key-value string pairs for rendering.
219    ///
220    /// Only attributes with `Some` values are included. This is used by
221    /// framework adapters to apply ARIA attributes to DOM elements.
222    pub fn to_attr_pairs(&self) -> Vec<(String, String)> {
223        let mut pairs = Vec::new();
224
225        if let Some(ref role) = self.role {
226            pairs.push(("role".to_string(), role.as_str().to_string()));
227        }
228        if let Some(ref label) = self.label {
229            pairs.push(("aria-label".to_string(), label.clone()));
230        }
231        if let Some(ref id) = self.labelledby {
232            pairs.push(("aria-labelledby".to_string(), id.clone()));
233        }
234        if let Some(ref id) = self.describedby {
235            pairs.push(("aria-describedby".to_string(), id.clone()));
236        }
237        if let Some(expanded) = self.expanded {
238            pairs.push(("aria-expanded".to_string(), expanded.to_string()));
239        }
240        if let Some(selected) = self.selected {
241            pairs.push(("aria-selected".to_string(), selected.to_string()));
242        }
243        if let Some(ref checked) = self.checked {
244            pairs.push(("aria-checked".to_string(), checked.as_str().to_string()));
245        }
246        if let Some(disabled) = self.disabled {
247            pairs.push(("aria-disabled".to_string(), disabled.to_string()));
248        }
249        if let Some(required) = self.required {
250            pairs.push(("aria-required".to_string(), required.to_string()));
251        }
252        if let Some(invalid) = self.invalid {
253            pairs.push(("aria-invalid".to_string(), invalid.to_string()));
254        }
255        if let Some(ref live) = self.live {
256            pairs.push(("aria-live".to_string(), live.as_str().to_string()));
257        }
258        if let Some(atomic) = self.atomic {
259            pairs.push(("aria-atomic".to_string(), atomic.to_string()));
260        }
261        if let Some(ref controls) = self.controls {
262            pairs.push(("aria-controls".to_string(), controls.clone()));
263        }
264        if let Some(ref owns) = self.owns {
265            pairs.push(("aria-owns".to_string(), owns.clone()));
266        }
267        if let Some(ref popup) = self.haspopup {
268            pairs.push(("aria-haspopup".to_string(), popup.as_str().to_string()));
269        }
270        if let Some(level) = self.level {
271            // ARIA heading levels must be 1-6; clamp to valid range
272            let clamped = level.clamp(1, 6);
273            pairs.push(("aria-level".to_string(), clamped.to_string()));
274        }
275        if let Some(ref orientation) = self.orientation {
276            pairs.push((
277                "aria-orientation".to_string(),
278                orientation.as_str().to_string(),
279            ));
280        }
281        if let Some(readonly) = self.readonly {
282            pairs.push(("aria-readonly".to_string(), readonly.to_string()));
283        }
284        if let Some(multi) = self.multiselectable {
285            pairs.push(("aria-multiselectable".to_string(), multi.to_string()));
286        }
287        if let Some(min) = self.valuemin {
288            pairs.push(("aria-valuemin".to_string(), min.to_string()));
289        }
290        if let Some(max) = self.valuemax {
291            pairs.push(("aria-valuemax".to_string(), max.to_string()));
292        }
293        if let Some(now) = self.valuenow {
294            pairs.push(("aria-valuenow".to_string(), now.to_string()));
295        }
296        if let Some(ref text) = self.valuetext {
297            pairs.push(("aria-valuetext".to_string(), text.clone()));
298        }
299        if let Some(hidden) = self.hidden {
300            pairs.push(("aria-hidden".to_string(), hidden.to_string()));
301        }
302        if let Some(ref id) = self.activedescendant {
303            pairs.push(("aria-activedescendant".to_string(), id.clone()));
304        }
305        if let Some(busy) = self.busy {
306            pairs.push(("aria-busy".to_string(), busy.to_string()));
307        }
308        if let Some(pos) = self.posinset {
309            pairs.push(("aria-posinset".to_string(), pos.to_string()));
310        }
311        if let Some(size) = self.setsize {
312            pairs.push(("aria-setsize".to_string(), size.to_string()));
313        }
314        if let Some(modal) = self.modal {
315            pairs.push(("aria-modal".to_string(), modal.to_string()));
316        }
317        if let Some(col) = self.colcount {
318            pairs.push(("aria-colcount".to_string(), col.to_string()));
319        }
320        if let Some(col) = self.colindex {
321            pairs.push(("aria-colindex".to_string(), col.to_string()));
322        }
323        if let Some(col) = self.colspan {
324            pairs.push(("aria-colspan".to_string(), col.to_string()));
325        }
326        if let Some(row) = self.rowcount {
327            pairs.push(("aria-rowcount".to_string(), row.to_string()));
328        }
329        if let Some(row) = self.rowindex {
330            pairs.push(("aria-rowindex".to_string(), row.to_string()));
331        }
332        if let Some(row) = self.rowspan {
333            pairs.push(("aria-rowspan".to_string(), row.to_string()));
334        }
335        if let Some(ref sort) = self.sort {
336            pairs.push(("aria-sort".to_string(), sort.as_str().to_string()));
337        }
338        if let Some(ref ac) = self.autocomplete {
339            pairs.push(("aria-autocomplete".to_string(), ac.as_str().to_string()));
340        }
341        if let Some(ref cur) = self.current {
342            pairs.push(("aria-current".to_string(), cur.as_str().to_string()));
343        }
344        if let Some(ref err) = self.errormessage {
345            pairs.push(("aria-errormessage".to_string(), err.clone()));
346        }
347        if let Some(ref ks) = self.keyshortcuts {
348            pairs.push(("aria-keyshortcuts".to_string(), ks.clone()));
349        }
350        if let Some(ref rd) = self.roledescription {
351            pairs.push(("aria-roledescription".to_string(), rd.clone()));
352        }
353        if let Some(ref ph) = self.placeholder {
354            pairs.push(("aria-placeholder".to_string(), ph.clone()));
355        }
356
357        pairs
358    }
359}
360
361/// ARIA roles as defined in the WAI-ARIA specification.
362///
363/// Covers all roles needed for NexusStratum's 50+ component types.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
365pub enum AriaRole {
366    Alert,
367    AlertDialog,
368    Button,
369    Checkbox,
370    Combobox,
371    Dialog,
372    Feed,
373    Grid,
374    GridCell,
375    Group,
376    Heading,
377    Img,
378    Link,
379    List,
380    ListItem,
381    ListBox,
382    Log,
383    Marquee,
384    Menu,
385    MenuBar,
386    MenuItem,
387    MenuItemCheckbox,
388    MenuItemRadio,
389    Navigation,
390    None,
391    Option,
392    Presentation,
393    ProgressBar,
394    Radio,
395    RadioGroup,
396    Region,
397    Row,
398    RowGroup,
399    RowHeader,
400    ScrollBar,
401    Search,
402    SearchBox,
403    Separator,
404    Slider,
405    SpinButton,
406    Status,
407    Switch,
408    Tab,
409    TabList,
410    TabPanel,
411    Table,
412    TextBox,
413    Timer,
414    ToolBar,
415    ToolTip,
416    Tree,
417    TreeGrid,
418    TreeItem,
419    ColumnHeader,
420    Cell,
421    Form,
422    Main,
423    Banner,
424    Complementary,
425    ContentInfo,
426    Definition,
427    Document,
428    Figure,
429    Note,
430    Term,
431    Application,
432}
433
434impl AriaRole {
435    /// Get the string representation for use in the `role` HTML attribute.
436    pub fn as_str(&self) -> &'static str {
437        match self {
438            Self::Alert => "alert",
439            Self::AlertDialog => "alertdialog",
440            Self::Button => "button",
441            Self::Checkbox => "checkbox",
442            Self::Combobox => "combobox",
443            Self::Dialog => "dialog",
444            Self::Feed => "feed",
445            Self::Grid => "grid",
446            Self::GridCell => "gridcell",
447            Self::Group => "group",
448            Self::Heading => "heading",
449            Self::Img => "img",
450            Self::Link => "link",
451            Self::List => "list",
452            Self::ListItem => "listitem",
453            Self::ListBox => "listbox",
454            Self::Log => "log",
455            Self::Marquee => "marquee",
456            Self::Menu => "menu",
457            Self::MenuBar => "menubar",
458            Self::MenuItem => "menuitem",
459            Self::MenuItemCheckbox => "menuitemcheckbox",
460            Self::MenuItemRadio => "menuitemradio",
461            Self::Navigation => "navigation",
462            Self::None => "none",
463            Self::Option => "option",
464            Self::Presentation => "presentation",
465            Self::ProgressBar => "progressbar",
466            Self::Radio => "radio",
467            Self::RadioGroup => "radiogroup",
468            Self::Region => "region",
469            Self::Row => "row",
470            Self::RowGroup => "rowgroup",
471            Self::RowHeader => "rowheader",
472            Self::ScrollBar => "scrollbar",
473            Self::Search => "search",
474            Self::SearchBox => "searchbox",
475            Self::Separator => "separator",
476            Self::Slider => "slider",
477            Self::SpinButton => "spinbutton",
478            Self::Status => "status",
479            Self::Switch => "switch",
480            Self::Tab => "tab",
481            Self::TabList => "tablist",
482            Self::TabPanel => "tabpanel",
483            Self::Table => "table",
484            Self::TextBox => "textbox",
485            Self::Timer => "timer",
486            Self::ToolBar => "toolbar",
487            Self::ToolTip => "tooltip",
488            Self::Tree => "tree",
489            Self::TreeGrid => "treegrid",
490            Self::TreeItem => "treeitem",
491            Self::ColumnHeader => "columnheader",
492            Self::Cell => "cell",
493            Self::Form => "form",
494            Self::Main => "main",
495            Self::Banner => "banner",
496            Self::Complementary => "complementary",
497            Self::ContentInfo => "contentinfo",
498            Self::Definition => "definition",
499            Self::Document => "document",
500            Self::Figure => "figure",
501            Self::Note => "note",
502            Self::Term => "term",
503            Self::Application => "application",
504        }
505    }
506}
507
508/// Tri-state value for checkboxes supporting indeterminate state.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
510pub enum TriState {
511    True,
512    False,
513    Mixed,
514}
515
516impl TriState {
517    pub fn as_str(&self) -> &'static str {
518        match self {
519            Self::True => "true",
520            Self::False => "false",
521            Self::Mixed => "mixed",
522        }
523    }
524
525    pub fn is_checked(&self) -> bool {
526        matches!(self, Self::True)
527    }
528
529    pub fn toggle(&self) -> Self {
530        match self {
531            Self::True => Self::False,
532            Self::False => Self::True,
533            Self::Mixed => Self::True,
534        }
535    }
536}
537
538impl From<bool> for TriState {
539    fn from(value: bool) -> Self {
540        if value { Self::True } else { Self::False }
541    }
542}
543
544/// ARIA live region politeness setting.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
546pub enum AriaLive {
547    Off,
548    Polite,
549    Assertive,
550}
551
552impl AriaLive {
553    pub fn as_str(&self) -> &'static str {
554        match self {
555            Self::Off => "off",
556            Self::Polite => "polite",
557            Self::Assertive => "assertive",
558        }
559    }
560}
561
562/// Type of popup an element triggers.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
564pub enum AriaHasPopup {
565    True,
566    Menu,
567    ListBox,
568    Tree,
569    Grid,
570    Dialog,
571}
572
573impl AriaHasPopup {
574    pub fn as_str(&self) -> &'static str {
575        match self {
576            Self::True => "true",
577            Self::Menu => "menu",
578            Self::ListBox => "listbox",
579            Self::Tree => "tree",
580            Self::Grid => "grid",
581            Self::Dialog => "dialog",
582        }
583    }
584}
585
586/// Orientation of a widget.
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
588pub enum Orientation {
589    Horizontal,
590    Vertical,
591}
592
593impl Orientation {
594    pub fn as_str(&self) -> &'static str {
595        match self {
596            Self::Horizontal => "horizontal",
597            Self::Vertical => "vertical",
598        }
599    }
600}
601
602/// Sort direction for sortable table columns.
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
604pub enum AriaSort {
605    None,
606    Ascending,
607    Descending,
608    Other,
609}
610
611impl AriaSort {
612    pub fn as_str(&self) -> &'static str {
613        match self {
614            Self::None => "none",
615            Self::Ascending => "ascending",
616            Self::Descending => "descending",
617            Self::Other => "other",
618        }
619    }
620}
621
622/// Autocomplete behavior.
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
624pub enum AriaAutocomplete {
625    None,
626    Inline,
627    List,
628    Both,
629}
630
631impl AriaAutocomplete {
632    pub fn as_str(&self) -> &'static str {
633        match self {
634            Self::None => "none",
635            Self::Inline => "inline",
636            Self::List => "list",
637            Self::Both => "both",
638        }
639    }
640}
641
642/// Current item state.
643#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
644pub enum AriaCurrent {
645    True,
646    Page,
647    Step,
648    Location,
649    Date,
650    Time,
651}
652
653impl AriaCurrent {
654    pub fn as_str(&self) -> &'static str {
655        match self {
656            Self::True => "true",
657            Self::Page => "page",
658            Self::Step => "step",
659            Self::Location => "location",
660            Self::Date => "date",
661            Self::Time => "time",
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn aria_attributes_default_is_empty() {
672        let attrs = AriaAttributes::default();
673        assert!(attrs.role.is_none());
674        assert!(attrs.label.is_none());
675        assert_eq!(attrs.to_attr_pairs().len(), 0);
676    }
677
678    #[test]
679    fn aria_attributes_builder() {
680        let attrs = AriaAttributes::new()
681            .with_role(AriaRole::Button)
682            .with_label("Click me")
683            .with_disabled(false)
684            .with_expanded(true);
685
686        assert_eq!(attrs.role, Some(AriaRole::Button));
687        assert_eq!(attrs.label, Some("Click me".to_string()));
688        assert_eq!(attrs.disabled, Some(false));
689        assert_eq!(attrs.expanded, Some(true));
690    }
691
692    #[test]
693    fn aria_attributes_to_attr_pairs() {
694        let attrs = AriaAttributes::new()
695            .with_role(AriaRole::Button)
696            .with_label("Save")
697            .with_expanded(false);
698
699        let pairs = attrs.to_attr_pairs();
700        assert!(pairs.contains(&("role".to_string(), "button".to_string())));
701        assert!(pairs.contains(&("aria-label".to_string(), "Save".to_string())));
702        assert!(pairs.contains(&("aria-expanded".to_string(), "false".to_string())));
703        assert_eq!(pairs.len(), 3);
704    }
705
706    #[test]
707    fn aria_role_as_str() {
708        assert_eq!(AriaRole::Button.as_str(), "button");
709        assert_eq!(AriaRole::Dialog.as_str(), "dialog");
710        assert_eq!(AriaRole::TabList.as_str(), "tablist");
711        assert_eq!(AriaRole::TreeItem.as_str(), "treeitem");
712        assert_eq!(AriaRole::AlertDialog.as_str(), "alertdialog");
713    }
714
715    #[test]
716    fn tri_state_toggle() {
717        assert_eq!(TriState::False.toggle(), TriState::True);
718        assert_eq!(TriState::True.toggle(), TriState::False);
719        assert_eq!(TriState::Mixed.toggle(), TriState::True);
720    }
721
722    #[test]
723    fn tri_state_from_bool() {
724        assert_eq!(TriState::from(true), TriState::True);
725        assert_eq!(TriState::from(false), TriState::False);
726    }
727
728    #[test]
729    fn aria_live_as_str() {
730        assert_eq!(AriaLive::Polite.as_str(), "polite");
731        assert_eq!(AriaLive::Assertive.as_str(), "assertive");
732        assert_eq!(AriaLive::Off.as_str(), "off");
733    }
734
735    #[test]
736    fn orientation_as_str() {
737        assert_eq!(Orientation::Horizontal.as_str(), "horizontal");
738        assert_eq!(Orientation::Vertical.as_str(), "vertical");
739    }
740
741    #[test]
742    fn aria_attributes_serialization() {
743        let attrs = AriaAttributes::new()
744            .with_role(AriaRole::Checkbox)
745            .with_checked(TriState::Mixed);
746
747        let json = serde_json::to_string(&attrs).unwrap();
748        let deserialized: AriaAttributes = serde_json::from_str(&json).unwrap();
749        assert_eq!(attrs, deserialized);
750    }
751
752    #[test]
753    fn all_aria_roles_have_str() {
754        let roles = vec![
755            AriaRole::Alert,
756            AriaRole::AlertDialog,
757            AriaRole::Button,
758            AriaRole::Checkbox,
759            AriaRole::Combobox,
760            AriaRole::Dialog,
761            AriaRole::Grid,
762            AriaRole::Group,
763            AriaRole::Heading,
764            AriaRole::Link,
765            AriaRole::List,
766            AriaRole::ListBox,
767            AriaRole::Menu,
768            AriaRole::MenuBar,
769            AriaRole::MenuItem,
770            AriaRole::Navigation,
771            AriaRole::ProgressBar,
772            AriaRole::Radio,
773            AriaRole::RadioGroup,
774            AriaRole::Separator,
775            AriaRole::Slider,
776            AriaRole::SpinButton,
777            AriaRole::Status,
778            AriaRole::Switch,
779            AriaRole::Tab,
780            AriaRole::TabList,
781            AriaRole::TabPanel,
782            AriaRole::Table,
783            AriaRole::TextBox,
784            AriaRole::ToolTip,
785            AriaRole::Tree,
786            AriaRole::TreeItem,
787        ];
788        for role in roles {
789            assert!(!role.as_str().is_empty());
790        }
791    }
792}