1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
12pub struct AriaAttributes {
13 pub role: Option<AriaRole>,
15
16 pub label: Option<String>,
18
19 pub labelledby: Option<String>,
21
22 pub describedby: Option<String>,
24
25 pub expanded: Option<bool>,
27
28 pub selected: Option<bool>,
30
31 pub checked: Option<TriState>,
33
34 pub disabled: Option<bool>,
36
37 pub required: Option<bool>,
39
40 pub invalid: Option<bool>,
42
43 pub live: Option<AriaLive>,
45
46 pub atomic: Option<bool>,
48
49 pub controls: Option<String>,
51
52 pub owns: Option<String>,
54
55 pub haspopup: Option<AriaHasPopup>,
57
58 pub level: Option<u8>,
60
61 pub orientation: Option<Orientation>,
63
64 pub readonly: Option<bool>,
66
67 pub multiselectable: Option<bool>,
69
70 pub valuemin: Option<f64>,
72
73 pub valuemax: Option<f64>,
75
76 pub valuenow: Option<f64>,
78
79 pub valuetext: Option<String>,
81
82 pub hidden: Option<bool>,
84
85 pub activedescendant: Option<String>,
87
88 pub busy: Option<bool>,
90
91 pub posinset: Option<u32>,
93
94 pub setsize: Option<u32>,
96
97 pub colcount: Option<u32>,
99
100 pub colindex: Option<u32>,
102
103 pub colspan: Option<u32>,
105
106 pub rowcount: Option<u32>,
108
109 pub rowindex: Option<u32>,
111
112 pub rowspan: Option<u32>,
114
115 pub sort: Option<AriaSort>,
117
118 pub autocomplete: Option<AriaAutocomplete>,
120
121 pub current: Option<AriaCurrent>,
123
124 pub errormessage: Option<String>,
126
127 pub keyshortcuts: Option<String>,
129
130 pub roledescription: Option<String>,
132
133 pub modal: Option<bool>,
135
136 pub placeholder: Option<String>,
138}
139
140impl AriaAttributes {
141 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn with_role(mut self, role: AriaRole) -> Self {
148 self.role = Some(role);
149 self
150 }
151
152 pub fn with_label(mut self, label: impl Into<String>) -> Self {
154 self.label = Some(label.into());
155 self
156 }
157
158 pub fn with_labelledby(mut self, id: impl Into<String>) -> Self {
160 self.labelledby = Some(id.into());
161 self
162 }
163
164 pub fn with_describedby(mut self, id: impl Into<String>) -> Self {
166 self.describedby = Some(id.into());
167 self
168 }
169
170 pub fn with_expanded(mut self, expanded: bool) -> Self {
172 self.expanded = Some(expanded);
173 self
174 }
175
176 pub fn with_selected(mut self, selected: bool) -> Self {
178 self.selected = Some(selected);
179 self
180 }
181
182 pub fn with_checked(mut self, checked: TriState) -> Self {
184 self.checked = Some(checked);
185 self
186 }
187
188 pub fn with_disabled(mut self, disabled: bool) -> Self {
190 self.disabled = Some(disabled);
191 self
192 }
193
194 pub fn with_controls(mut self, id: impl Into<String>) -> Self {
196 self.controls = Some(id.into());
197 self
198 }
199
200 pub fn with_modal(mut self, modal: bool) -> Self {
202 self.modal = Some(modal);
203 self
204 }
205
206 pub fn with_haspopup(mut self, popup: AriaHasPopup) -> Self {
208 self.haspopup = Some(popup);
209 self
210 }
211
212 pub fn with_orientation(mut self, orientation: Orientation) -> Self {
214 self.orientation = Some(orientation);
215 self
216 }
217
218 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 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#[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 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#[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#[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#[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#[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#[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#[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#[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}