1use presentar_core::widget::AccessibleRole;
12use presentar_core::{Color, Widget};
13
14pub const MIN_TOUCH_TARGET_SIZE: f32 = 44.0;
16
17pub const MIN_FOCUS_INDICATOR_AREA: f32 = 2.0;
19
20pub struct A11yChecker;
22
23impl A11yChecker {
24 #[must_use]
26 pub fn check(widget: &dyn Widget) -> A11yReport {
27 let mut violations = Vec::new();
28 let mut context = CheckContext::default();
29 Self::check_widget(widget, &mut violations, &mut context);
30 A11yReport { violations }
31 }
32
33 #[must_use]
35 pub fn check_with_config(widget: &dyn Widget, config: &A11yConfig) -> A11yReport {
36 let mut violations = Vec::new();
37 let mut context = CheckContext {
38 check_touch_targets: config.check_touch_targets,
39 check_heading_hierarchy: config.check_heading_hierarchy,
40 check_focus_indicators: config.check_focus_indicators,
41 ..Default::default()
42 };
43 Self::check_widget(widget, &mut violations, &mut context);
44 A11yReport { violations }
45 }
46
47 fn check_widget(
48 widget: &dyn Widget,
49 violations: &mut Vec<A11yViolation>,
50 context: &mut CheckContext,
51 ) {
52 if widget.is_interactive() && widget.accessible_name().is_none() {
54 violations.push(A11yViolation {
55 rule: "aria-label".to_string(),
56 message: "Interactive element missing accessible name".to_string(),
57 wcag: "4.1.2".to_string(),
58 impact: Impact::Critical,
59 });
60 }
61
62 if widget.is_interactive() && !widget.is_focusable() {
64 violations.push(A11yViolation {
65 rule: "keyboard".to_string(),
66 message: "Interactive element is not keyboard focusable".to_string(),
67 wcag: "2.1.1".to_string(),
68 impact: Impact::Critical,
69 });
70 }
71
72 if context.check_touch_targets && widget.is_interactive() {
74 let bounds = widget.bounds();
75 if bounds.width < MIN_TOUCH_TARGET_SIZE || bounds.height < MIN_TOUCH_TARGET_SIZE {
76 violations.push(A11yViolation {
77 rule: "touch-target".to_string(),
78 message: format!(
79 "Touch target too small: {}x{} (minimum {}x{})",
80 bounds.width, bounds.height, MIN_TOUCH_TARGET_SIZE, MIN_TOUCH_TARGET_SIZE
81 ),
82 wcag: "2.5.5".to_string(),
83 impact: Impact::Moderate,
84 });
85 }
86 }
87
88 if context.check_heading_hierarchy && widget.accessible_role() == AccessibleRole::Heading {
90 if let Some(level) = Self::heading_level(widget) {
91 let last_level = context.last_heading_level;
92 if last_level > 0 && level > last_level + 1 {
93 violations.push(A11yViolation {
94 rule: "heading-order".to_string(),
95 message: format!(
96 "Heading level skipped: h{} followed by h{} (should be h{} or lower)",
97 last_level,
98 level,
99 last_level + 1
100 ),
101 wcag: "1.3.1".to_string(),
102 impact: Impact::Moderate,
103 });
104 }
105 context.last_heading_level = level;
106 }
107 }
108
109 if context.check_focus_indicators && widget.is_focusable() {
111 if !Self::has_visible_focus_indicator(widget) {
112 violations.push(A11yViolation {
113 rule: "focus-visible".to_string(),
114 message: "Focusable element may lack visible focus indicator".to_string(),
115 wcag: "2.4.7".to_string(),
116 impact: Impact::Serious,
117 });
118 }
119 }
120
121 if widget.accessible_role() == AccessibleRole::Image && widget.accessible_name().is_none() {
123 violations.push(A11yViolation {
124 rule: "image-alt".to_string(),
125 message: "Image missing alternative text".to_string(),
126 wcag: "1.1.1".to_string(),
127 impact: Impact::Critical,
128 });
129 }
130
131 for child in widget.children() {
133 Self::check_widget(child.as_ref(), violations, context);
134 }
135 }
136
137 fn heading_level(widget: &dyn Widget) -> Option<u8> {
139 if let Some(name) = widget.accessible_name() {
142 if name.starts_with('h') || name.starts_with('H') {
144 if let Ok(level) = name[1..2].parse::<u8>() {
145 if (1..=6).contains(&level) {
146 return Some(level);
147 }
148 }
149 }
150 }
151 Some(2)
153 }
154
155 fn has_visible_focus_indicator(widget: &dyn Widget) -> bool {
157 widget.is_focusable()
160 }
161
162 #[must_use]
164 pub fn check_contrast(
165 foreground: &Color,
166 background: &Color,
167 large_text: bool,
168 ) -> ContrastResult {
169 let ratio = foreground.contrast_ratio(background);
170
171 let (aa_threshold, aaa_threshold) = if large_text {
173 (3.0, 4.5) } else {
175 (4.5, 7.0) };
177
178 ContrastResult {
179 ratio,
180 passes_aa: ratio >= aa_threshold,
181 passes_aaa: ratio >= aaa_threshold,
182 }
183 }
184}
185
186#[derive(Debug)]
188pub struct A11yReport {
189 pub violations: Vec<A11yViolation>,
191}
192
193impl A11yReport {
194 #[must_use]
196 pub fn is_passing(&self) -> bool {
197 self.violations.is_empty()
198 }
199
200 #[must_use]
202 pub fn critical(&self) -> Vec<&A11yViolation> {
203 self.violations
204 .iter()
205 .filter(|v| v.impact == Impact::Critical)
206 .collect()
207 }
208
209 pub fn assert_pass(&self) {
215 if !self.is_passing() {
216 let messages: Vec<String> = self
217 .violations
218 .iter()
219 .map(|v| {
220 format!(
221 " [{:?}] {}: {} (WCAG {})",
222 v.impact, v.rule, v.message, v.wcag
223 )
224 })
225 .collect();
226
227 panic!(
228 "Accessibility check failed with {} violation(s):\n{}",
229 self.violations.len(),
230 messages.join("\n")
231 );
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct A11yViolation {
239 pub rule: String,
241 pub message: String,
243 pub wcag: String,
245 pub impact: Impact,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Impact {
252 Minor,
254 Moderate,
256 Serious,
258 Critical,
260}
261
262#[derive(Debug, Clone)]
264pub struct A11yConfig {
265 pub check_touch_targets: bool,
267 pub check_heading_hierarchy: bool,
269 pub check_focus_indicators: bool,
271 pub min_contrast_normal: f32,
273 pub min_contrast_large: f32,
275}
276
277impl Default for A11yConfig {
278 fn default() -> Self {
279 Self {
280 check_touch_targets: true,
281 check_heading_hierarchy: true,
282 check_focus_indicators: false, min_contrast_normal: 4.5,
284 min_contrast_large: 3.0,
285 }
286 }
287}
288
289impl A11yConfig {
290 #[must_use]
292 pub fn strict() -> Self {
293 Self {
294 check_touch_targets: true,
295 check_heading_hierarchy: true,
296 check_focus_indicators: true,
297 min_contrast_normal: 7.0, min_contrast_large: 4.5, }
300 }
301
302 #[must_use]
304 pub fn mobile() -> Self {
305 Self {
306 check_touch_targets: true,
307 check_heading_hierarchy: true,
308 check_focus_indicators: false,
309 min_contrast_normal: 4.5,
310 min_contrast_large: 3.0,
311 }
312 }
313}
314
315#[derive(Debug, Default)]
317struct CheckContext {
318 last_heading_level: u8,
320 check_touch_targets: bool,
322 check_heading_hierarchy: bool,
324 check_focus_indicators: bool,
326}
327
328#[derive(Debug, Clone)]
330pub struct ContrastResult {
331 pub ratio: f32,
333 pub passes_aa: bool,
335 pub passes_aaa: bool,
337}
338
339#[derive(Debug, Clone, Default)]
345pub struct AriaAttributes {
346 pub role: Option<String>,
348 pub label: Option<String>,
350 pub described_by: Option<String>,
352 pub hidden: bool,
354 pub expanded: Option<bool>,
356 pub selected: Option<bool>,
358 pub checked: Option<AriaChecked>,
360 pub pressed: Option<AriaChecked>,
362 pub disabled: bool,
364 pub required: bool,
366 pub invalid: bool,
368 pub value_now: Option<f64>,
370 pub value_min: Option<f64>,
372 pub value_max: Option<f64>,
374 pub value_text: Option<String>,
376 pub level: Option<u8>,
378 pub pos_in_set: Option<u32>,
380 pub set_size: Option<u32>,
382 pub controls: Option<String>,
384 pub has_popup: Option<String>,
386 pub busy: bool,
388 pub live: Option<AriaLive>,
390 pub atomic: bool,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AriaChecked {
397 True,
399 False,
401 Mixed,
403}
404
405impl AriaChecked {
406 #[must_use]
408 pub const fn as_str(&self) -> &'static str {
409 match self {
410 AriaChecked::True => "true",
411 AriaChecked::False => "false",
412 AriaChecked::Mixed => "mixed",
413 }
414 }
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum AriaLive {
420 Polite,
422 Assertive,
424 Off,
426}
427
428impl AriaLive {
429 #[must_use]
431 pub const fn as_str(&self) -> &'static str {
432 match self {
433 AriaLive::Polite => "polite",
434 AriaLive::Assertive => "assertive",
435 AriaLive::Off => "off",
436 }
437 }
438}
439
440impl AriaAttributes {
441 #[must_use]
443 pub fn new() -> Self {
444 Self::default()
445 }
446
447 #[must_use]
449 pub fn with_role(mut self, role: impl Into<String>) -> Self {
450 self.role = Some(role.into());
451 self
452 }
453
454 #[must_use]
456 pub fn with_label(mut self, label: impl Into<String>) -> Self {
457 self.label = Some(label.into());
458 self
459 }
460
461 #[must_use]
463 pub const fn with_hidden(mut self, hidden: bool) -> Self {
464 self.hidden = hidden;
465 self
466 }
467
468 #[must_use]
470 pub const fn with_expanded(mut self, expanded: bool) -> Self {
471 self.expanded = Some(expanded);
472 self
473 }
474
475 #[must_use]
477 pub const fn with_selected(mut self, selected: bool) -> Self {
478 self.selected = Some(selected);
479 self
480 }
481
482 #[must_use]
484 pub const fn with_checked(mut self, checked: AriaChecked) -> Self {
485 self.checked = Some(checked);
486 self
487 }
488
489 #[must_use]
491 pub const fn with_pressed(mut self, pressed: AriaChecked) -> Self {
492 self.pressed = Some(pressed);
493 self
494 }
495
496 #[must_use]
498 pub const fn with_disabled(mut self, disabled: bool) -> Self {
499 self.disabled = disabled;
500 self
501 }
502
503 #[must_use]
505 pub const fn with_busy(mut self, busy: bool) -> Self {
506 self.busy = busy;
507 self
508 }
509
510 #[must_use]
512 pub const fn with_atomic(mut self, atomic: bool) -> Self {
513 self.atomic = atomic;
514 self
515 }
516
517 #[must_use]
519 pub fn with_range(mut self, min: f64, max: f64, now: f64) -> Self {
520 self.value_min = Some(min);
521 self.value_max = Some(max);
522 self.value_now = Some(now);
523 self
524 }
525
526 #[must_use]
528 pub const fn with_value_now(mut self, value: f64) -> Self {
529 self.value_now = Some(value);
530 self
531 }
532
533 #[must_use]
535 pub const fn with_value_min(mut self, value: f64) -> Self {
536 self.value_min = Some(value);
537 self
538 }
539
540 #[must_use]
542 pub const fn with_value_max(mut self, value: f64) -> Self {
543 self.value_max = Some(value);
544 self
545 }
546
547 #[must_use]
549 pub fn with_controls(mut self, controls: impl Into<String>) -> Self {
550 self.controls = Some(controls.into());
551 self
552 }
553
554 #[must_use]
556 pub fn with_described_by(mut self, described_by: impl Into<String>) -> Self {
557 self.described_by = Some(described_by.into());
558 self
559 }
560
561 #[must_use]
563 pub fn with_has_popup(mut self, has_popup: impl Into<String>) -> Self {
564 self.has_popup = Some(has_popup.into());
565 self
566 }
567
568 #[must_use]
570 pub const fn with_level(mut self, level: u8) -> Self {
571 self.level = Some(level);
572 self
573 }
574
575 #[must_use]
577 pub const fn with_pos_in_set(mut self, pos: u32, size: u32) -> Self {
578 self.pos_in_set = Some(pos);
579 self.set_size = Some(size);
580 self
581 }
582
583 #[must_use]
585 pub const fn with_live(mut self, live: AriaLive) -> Self {
586 self.live = Some(live);
587 self
588 }
589
590 #[must_use]
592 pub fn to_html_attrs(&self) -> Vec<(String, String)> {
593 let mut attrs = Vec::new();
594
595 if let Some(ref role) = self.role {
596 attrs.push(("role".to_string(), role.clone()));
597 }
598 if let Some(ref label) = self.label {
599 attrs.push(("aria-label".to_string(), label.clone()));
600 }
601 if let Some(ref desc) = self.described_by {
602 attrs.push(("aria-describedby".to_string(), desc.clone()));
603 }
604 if self.hidden {
605 attrs.push(("aria-hidden".to_string(), "true".to_string()));
606 }
607 if let Some(expanded) = self.expanded {
608 attrs.push(("aria-expanded".to_string(), expanded.to_string()));
609 }
610 if let Some(selected) = self.selected {
611 attrs.push(("aria-selected".to_string(), selected.to_string()));
612 }
613 if let Some(checked) = self.checked {
614 attrs.push(("aria-checked".to_string(), checked.as_str().to_string()));
615 }
616 if let Some(pressed) = self.pressed {
617 attrs.push(("aria-pressed".to_string(), pressed.as_str().to_string()));
618 }
619 if let Some(ref popup) = self.has_popup {
620 attrs.push(("aria-haspopup".to_string(), popup.clone()));
621 }
622 if self.disabled {
623 attrs.push(("aria-disabled".to_string(), "true".to_string()));
624 }
625 if self.required {
626 attrs.push(("aria-required".to_string(), "true".to_string()));
627 }
628 if self.invalid {
629 attrs.push(("aria-invalid".to_string(), "true".to_string()));
630 }
631 if let Some(val) = self.value_now {
632 attrs.push(("aria-valuenow".to_string(), val.to_string()));
633 }
634 if let Some(val) = self.value_min {
635 attrs.push(("aria-valuemin".to_string(), val.to_string()));
636 }
637 if let Some(val) = self.value_max {
638 attrs.push(("aria-valuemax".to_string(), val.to_string()));
639 }
640 if let Some(ref text) = self.value_text {
641 attrs.push(("aria-valuetext".to_string(), text.clone()));
642 }
643 if let Some(level) = self.level {
644 attrs.push(("aria-level".to_string(), level.to_string()));
645 }
646 if let Some(pos) = self.pos_in_set {
647 attrs.push(("aria-posinset".to_string(), pos.to_string()));
648 }
649 if let Some(size) = self.set_size {
650 attrs.push(("aria-setsize".to_string(), size.to_string()));
651 }
652 if let Some(ref controls) = self.controls {
653 attrs.push(("aria-controls".to_string(), controls.clone()));
654 }
655 if self.busy {
656 attrs.push(("aria-busy".to_string(), "true".to_string()));
657 }
658 if let Some(live) = self.live {
659 attrs.push(("aria-live".to_string(), live.as_str().to_string()));
660 }
661 if self.atomic {
662 attrs.push(("aria-atomic".to_string(), "true".to_string()));
663 }
664
665 attrs
666 }
667
668 #[must_use]
670 pub fn to_html_string(&self) -> String {
671 self.to_html_attrs()
672 .into_iter()
673 .map(|(k, v)| {
674 let escaped = v
676 .replace('&', "&")
677 .replace('"', """)
678 .replace('<', "<")
679 .replace('>', ">");
680 format!("{}=\"{}\"", k, escaped)
681 })
682 .collect::<Vec<_>>()
683 .join(" ")
684 }
685}
686
687pub fn aria_from_widget(widget: &dyn Widget) -> AriaAttributes {
689 use presentar_core::widget::AccessibleRole;
690
691 let mut attrs = AriaAttributes::new();
692
693 let role = match widget.accessible_role() {
695 AccessibleRole::Generic => None,
696 AccessibleRole::Button => Some("button"),
697 AccessibleRole::Checkbox => Some("checkbox"),
698 AccessibleRole::TextInput => Some("textbox"),
699 AccessibleRole::Link => Some("link"),
700 AccessibleRole::Heading => Some("heading"),
701 AccessibleRole::Image => Some("img"),
702 AccessibleRole::List => Some("list"),
703 AccessibleRole::ListItem => Some("listitem"),
704 AccessibleRole::Table => Some("table"),
705 AccessibleRole::TableRow => Some("row"),
706 AccessibleRole::TableCell => Some("cell"),
707 AccessibleRole::Menu => Some("menu"),
708 AccessibleRole::MenuItem => Some("menuitem"),
709 AccessibleRole::ComboBox => Some("combobox"),
710 AccessibleRole::Slider => Some("slider"),
711 AccessibleRole::ProgressBar => Some("progressbar"),
712 AccessibleRole::Tab => Some("tab"),
713 AccessibleRole::TabPanel => Some("tabpanel"),
714 AccessibleRole::RadioGroup => Some("radiogroup"),
715 AccessibleRole::Radio => Some("radio"),
716 };
717
718 if let Some(role) = role {
719 attrs.role = Some(role.to_string());
720 }
721
722 if let Some(name) = widget.accessible_name() {
724 attrs.label = Some(name.to_string());
725 }
726
727 if !widget.is_interactive() && widget.accessible_role() != AccessibleRole::Generic {
729 attrs.disabled = true;
730 }
731
732 attrs
733}
734
735pub struct FormA11yChecker;
741
742impl FormA11yChecker {
743 #[must_use]
752 pub fn check(form: &FormAccessibility) -> FormA11yReport {
753 let mut violations = Vec::new();
754
755 for field in &form.fields {
757 Self::check_field(field, &mut violations);
758 }
759
760 Self::check_form_level(form, &mut violations);
762
763 FormA11yReport { violations }
764 }
765
766 fn check_field(field: &FormFieldA11y, violations: &mut Vec<FormViolation>) {
768 if field.label.is_none() && field.aria_label.is_none() && field.aria_labelledby.is_none() {
770 violations.push(FormViolation {
771 field_id: field.id.clone(),
772 rule: FormA11yRule::MissingLabel,
773 message: format!("Field '{}' has no associated label", field.id),
774 wcag: "1.3.1, 2.4.6".to_string(),
775 impact: Impact::Critical,
776 });
777 }
778
779 if field.required {
781 if !field.aria_required {
782 violations.push(FormViolation {
783 field_id: field.id.clone(),
784 rule: FormA11yRule::MissingRequiredIndicator,
785 message: format!(
786 "Required field '{}' does not have aria-required=\"true\"",
787 field.id
788 ),
789 wcag: "3.3.2".to_string(),
790 impact: Impact::Serious,
791 });
792 }
793 if !field.has_visual_required_indicator {
794 violations.push(FormViolation {
795 field_id: field.id.clone(),
796 rule: FormA11yRule::MissingVisualRequired,
797 message: format!(
798 "Required field '{}' lacks visual required indicator (asterisk or text)",
799 field.id
800 ),
801 wcag: "3.3.2".to_string(),
802 impact: Impact::Moderate,
803 });
804 }
805 }
806
807 if field.has_error {
809 if !field.aria_invalid {
810 violations.push(FormViolation {
811 field_id: field.id.clone(),
812 rule: FormA11yRule::MissingErrorState,
813 message: format!(
814 "Field '{}' in error state does not have aria-invalid=\"true\"",
815 field.id
816 ),
817 wcag: "3.3.1".to_string(),
818 impact: Impact::Serious,
819 });
820 }
821 if field.error_message.is_none() {
822 violations.push(FormViolation {
823 field_id: field.id.clone(),
824 rule: FormA11yRule::MissingErrorMessage,
825 message: format!("Field '{}' in error state has no error message", field.id),
826 wcag: "3.3.1".to_string(),
827 impact: Impact::Serious,
828 });
829 }
830 if field.aria_describedby.is_none() && field.aria_errormessage.is_none() {
831 violations.push(FormViolation {
832 field_id: field.id.clone(),
833 rule: FormA11yRule::ErrorNotAssociated,
834 message: format!(
835 "Error message for '{}' not associated via aria-describedby or aria-errormessage",
836 field.id
837 ),
838 wcag: "3.3.1".to_string(),
839 impact: Impact::Serious,
840 });
841 }
842 }
843
844 if let Some(ref input_type) = field.input_type {
846 if input_type.should_have_autocomplete() && field.autocomplete.is_none() {
847 violations.push(FormViolation {
848 field_id: field.id.clone(),
849 rule: FormA11yRule::MissingAutocomplete,
850 message: format!(
851 "Field '{}' of type {:?} should have autocomplete attribute for autofill",
852 field.id, input_type
853 ),
854 wcag: "1.3.5".to_string(),
855 impact: Impact::Moderate,
856 });
857 }
858 }
859
860 if field.placeholder.is_some() && field.label.is_none() && field.aria_label.is_none() {
862 violations.push(FormViolation {
863 field_id: field.id.clone(),
864 rule: FormA11yRule::PlaceholderAsLabel,
865 message: format!(
866 "Field '{}' uses placeholder as sole label; placeholders disappear on input",
867 field.id
868 ),
869 wcag: "3.3.2".to_string(),
870 impact: Impact::Serious,
871 });
872 }
873 }
874
875 fn check_form_level(form: &FormAccessibility, violations: &mut Vec<FormViolation>) {
877 let radio_fields: Vec<_> = form
879 .fields
880 .iter()
881 .filter(|f| f.input_type == Some(InputType::Radio))
882 .collect();
883
884 if radio_fields.len() > 1 {
885 let has_group = form.field_groups.iter().any(|g| {
887 g.field_ids
888 .iter()
889 .any(|id| radio_fields.iter().any(|f| &f.id == id))
890 });
891
892 if !has_group {
893 violations.push(FormViolation {
894 field_id: "form".to_string(),
895 rule: FormA11yRule::RelatedFieldsNotGrouped,
896 message: "Related radio buttons should be grouped in a fieldset with legend"
897 .to_string(),
898 wcag: "1.3.1".to_string(),
899 impact: Impact::Moderate,
900 });
901 }
902 }
903
904 for group in &form.field_groups {
906 if group.legend.is_none() && group.aria_label.is_none() {
907 violations.push(FormViolation {
908 field_id: group.id.clone(),
909 rule: FormA11yRule::GroupMissingLegend,
910 message: format!("Field group '{}' has no legend or aria-label", group.id),
911 wcag: "1.3.1".to_string(),
912 impact: Impact::Serious,
913 });
914 }
915 }
916
917 if form.accessible_name.is_none() && form.aria_labelledby.is_none() {
919 violations.push(FormViolation {
920 field_id: "form".to_string(),
921 rule: FormA11yRule::FormMissingName,
922 message: "Form should have an accessible name (aria-label or aria-labelledby)"
923 .to_string(),
924 wcag: "4.1.2".to_string(),
925 impact: Impact::Moderate,
926 });
927 }
928 }
929}
930
931#[derive(Debug, Clone, Default)]
933pub struct FormAccessibility {
934 pub accessible_name: Option<String>,
936 pub aria_labelledby: Option<String>,
938 pub fields: Vec<FormFieldA11y>,
940 pub field_groups: Vec<FormFieldGroup>,
942}
943
944impl FormAccessibility {
945 #[must_use]
947 pub fn new() -> Self {
948 Self::default()
949 }
950
951 #[must_use]
953 pub fn field(mut self, field: FormFieldA11y) -> Self {
954 self.fields.push(field);
955 self
956 }
957
958 #[must_use]
960 pub fn group(mut self, group: FormFieldGroup) -> Self {
961 self.field_groups.push(group);
962 self
963 }
964
965 #[must_use]
967 pub fn with_name(mut self, name: impl Into<String>) -> Self {
968 self.accessible_name = Some(name.into());
969 self
970 }
971}
972
973#[derive(Debug, Clone, Default)]
975pub struct FormFieldA11y {
976 pub id: String,
978 pub label: Option<String>,
980 pub input_type: Option<InputType>,
982 pub required: bool,
984 pub has_visual_required_indicator: bool,
986 pub aria_required: bool,
988 pub aria_label: Option<String>,
990 pub aria_labelledby: Option<String>,
992 pub aria_describedby: Option<String>,
994 pub has_error: bool,
996 pub aria_invalid: bool,
998 pub aria_errormessage: Option<String>,
1000 pub error_message: Option<String>,
1002 pub autocomplete: Option<AutocompleteValue>,
1004 pub placeholder: Option<String>,
1006}
1007
1008impl FormFieldA11y {
1009 #[must_use]
1011 pub fn new(id: impl Into<String>) -> Self {
1012 Self {
1013 id: id.into(),
1014 ..Default::default()
1015 }
1016 }
1017
1018 #[must_use]
1020 pub fn with_label(mut self, label: impl Into<String>) -> Self {
1021 self.label = Some(label.into());
1022 self
1023 }
1024
1025 #[must_use]
1027 pub fn with_type(mut self, input_type: InputType) -> Self {
1028 self.input_type = Some(input_type);
1029 self
1030 }
1031
1032 #[must_use]
1034 pub fn required(mut self) -> Self {
1035 self.required = true;
1036 self.aria_required = true;
1037 self.has_visual_required_indicator = true;
1038 self
1039 }
1040
1041 #[must_use]
1043 pub fn with_required(mut self, visual: bool, aria: bool) -> Self {
1044 self.required = true;
1045 self.has_visual_required_indicator = visual;
1046 self.aria_required = aria;
1047 self
1048 }
1049
1050 #[must_use]
1052 pub fn with_error(mut self, message: impl Into<String>, associated: bool) -> Self {
1053 self.has_error = true;
1054 self.aria_invalid = true;
1055 self.error_message = Some(message.into());
1056 if associated {
1057 self.aria_describedby = Some(format!("{}-error", self.id));
1058 }
1059 self
1060 }
1061
1062 #[must_use]
1064 pub fn with_autocomplete(mut self, value: AutocompleteValue) -> Self {
1065 self.autocomplete = Some(value);
1066 self
1067 }
1068
1069 #[must_use]
1071 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
1072 self.placeholder = Some(placeholder.into());
1073 self
1074 }
1075
1076 #[must_use]
1078 pub fn with_aria_label(mut self, label: impl Into<String>) -> Self {
1079 self.aria_label = Some(label.into());
1080 self
1081 }
1082}
1083
1084#[derive(Debug, Clone, Default)]
1086pub struct FormFieldGroup {
1087 pub id: String,
1089 pub legend: Option<String>,
1091 pub aria_label: Option<String>,
1093 pub field_ids: Vec<String>,
1095}
1096
1097impl FormFieldGroup {
1098 #[must_use]
1100 pub fn new(id: impl Into<String>) -> Self {
1101 Self {
1102 id: id.into(),
1103 ..Default::default()
1104 }
1105 }
1106
1107 #[must_use]
1109 pub fn with_legend(mut self, legend: impl Into<String>) -> Self {
1110 self.legend = Some(legend.into());
1111 self
1112 }
1113
1114 #[must_use]
1116 pub fn with_field(mut self, field_id: impl Into<String>) -> Self {
1117 self.field_ids.push(field_id.into());
1118 self
1119 }
1120}
1121
1122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1124pub enum InputType {
1125 Text,
1126 Email,
1127 Password,
1128 Tel,
1129 Url,
1130 Number,
1131 Date,
1132 Time,
1133 Search,
1134 Radio,
1135 Checkbox,
1136 Select,
1137 Textarea,
1138 Hidden,
1139}
1140
1141impl InputType {
1142 #[must_use]
1144 pub const fn should_have_autocomplete(&self) -> bool {
1145 matches!(
1146 self,
1147 Self::Text | Self::Email | Self::Password | Self::Tel | Self::Url | Self::Number
1148 )
1149 }
1150}
1151
1152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1154pub enum AutocompleteValue {
1155 Name,
1157 GivenName,
1159 FamilyName,
1161 Email,
1163 Tel,
1165 StreetAddress,
1167 AddressLevel1,
1169 AddressLevel2,
1171 PostalCode,
1173 Country,
1175 Organization,
1177 Username,
1179 CurrentPassword,
1181 NewPassword,
1183 CcNumber,
1185 CcExp,
1187 CcCsc,
1189 OneTimeCode,
1191 Off,
1193}
1194
1195impl AutocompleteValue {
1196 #[must_use]
1198 pub const fn as_str(&self) -> &'static str {
1199 match self {
1200 Self::Name => "name",
1201 Self::GivenName => "given-name",
1202 Self::FamilyName => "family-name",
1203 Self::Email => "email",
1204 Self::Tel => "tel",
1205 Self::StreetAddress => "street-address",
1206 Self::AddressLevel1 => "address-level1",
1207 Self::AddressLevel2 => "address-level2",
1208 Self::PostalCode => "postal-code",
1209 Self::Country => "country",
1210 Self::Organization => "organization",
1211 Self::Username => "username",
1212 Self::CurrentPassword => "current-password",
1213 Self::NewPassword => "new-password",
1214 Self::CcNumber => "cc-number",
1215 Self::CcExp => "cc-exp",
1216 Self::CcCsc => "cc-csc",
1217 Self::OneTimeCode => "one-time-code",
1218 Self::Off => "off",
1219 }
1220 }
1221}
1222
1223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1225pub enum FormA11yRule {
1226 MissingLabel,
1228 MissingRequiredIndicator,
1230 MissingVisualRequired,
1232 MissingErrorState,
1234 MissingErrorMessage,
1236 ErrorNotAssociated,
1238 MissingAutocomplete,
1240 PlaceholderAsLabel,
1242 RelatedFieldsNotGrouped,
1244 GroupMissingLegend,
1246 FormMissingName,
1248}
1249
1250impl FormA11yRule {
1251 #[must_use]
1253 pub const fn name(&self) -> &'static str {
1254 match self {
1255 Self::MissingLabel => "missing-label",
1256 Self::MissingRequiredIndicator => "missing-required-indicator",
1257 Self::MissingVisualRequired => "missing-visual-required",
1258 Self::MissingErrorState => "missing-error-state",
1259 Self::MissingErrorMessage => "missing-error-message",
1260 Self::ErrorNotAssociated => "error-not-associated",
1261 Self::MissingAutocomplete => "missing-autocomplete",
1262 Self::PlaceholderAsLabel => "placeholder-as-label",
1263 Self::RelatedFieldsNotGrouped => "related-fields-not-grouped",
1264 Self::GroupMissingLegend => "group-missing-legend",
1265 Self::FormMissingName => "form-missing-name",
1266 }
1267 }
1268}
1269
1270#[derive(Debug, Clone)]
1272pub struct FormViolation {
1273 pub field_id: String,
1275 pub rule: FormA11yRule,
1277 pub message: String,
1279 pub wcag: String,
1281 pub impact: Impact,
1283}
1284
1285#[derive(Debug)]
1287pub struct FormA11yReport {
1288 pub violations: Vec<FormViolation>,
1290}
1291
1292impl FormA11yReport {
1293 #[must_use]
1295 pub fn is_passing(&self) -> bool {
1296 self.violations.is_empty()
1297 }
1298
1299 #[must_use]
1301 pub fn is_acceptable(&self) -> bool {
1302 !self
1303 .violations
1304 .iter()
1305 .any(|v| matches!(v.impact, Impact::Critical | Impact::Serious))
1306 }
1307
1308 #[must_use]
1310 pub fn violations_for_rule(&self, rule: FormA11yRule) -> Vec<&FormViolation> {
1311 self.violations.iter().filter(|v| v.rule == rule).collect()
1312 }
1313
1314 #[must_use]
1316 pub fn violations_for_field(&self, field_id: &str) -> Vec<&FormViolation> {
1317 self.violations
1318 .iter()
1319 .filter(|v| v.field_id == field_id)
1320 .collect()
1321 }
1322
1323 pub fn assert_pass(&self) {
1329 if !self.is_passing() {
1330 let messages: Vec<String> = self
1331 .violations
1332 .iter()
1333 .map(|v| {
1334 format!(
1335 " [{:?}] {} ({}): {} (WCAG {})",
1336 v.impact,
1337 v.rule.name(),
1338 v.field_id,
1339 v.message,
1340 v.wcag
1341 )
1342 })
1343 .collect();
1344
1345 panic!(
1346 "Form accessibility check failed with {} violation(s):\n{}",
1347 self.violations.len(),
1348 messages.join("\n")
1349 );
1350 }
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use presentar_core::{
1358 widget::{AccessibleRole, LayoutResult},
1359 Canvas, Constraints, Event, Rect, Size, TypeId,
1360 };
1361 use std::any::Any;
1362
1363 struct MockButton {
1365 accessible_name: Option<String>,
1366 focusable: bool,
1367 }
1368
1369 impl MockButton {
1370 fn new() -> Self {
1371 Self {
1372 accessible_name: None,
1373 focusable: true,
1374 }
1375 }
1376
1377 fn with_name(mut self, name: &str) -> Self {
1378 self.accessible_name = Some(name.to_string());
1379 self
1380 }
1381
1382 fn not_focusable(mut self) -> Self {
1383 self.focusable = false;
1384 self
1385 }
1386 }
1387
1388 impl Widget for MockButton {
1389 fn type_id(&self) -> TypeId {
1390 TypeId::of::<Self>()
1391 }
1392 fn measure(&self, c: Constraints) -> Size {
1393 c.smallest()
1394 }
1395 fn layout(&mut self, b: Rect) -> LayoutResult {
1396 LayoutResult { size: b.size() }
1397 }
1398 fn paint(&self, _: &mut dyn Canvas) {}
1399 fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1400 None
1401 }
1402 fn children(&self) -> &[Box<dyn Widget>] {
1403 &[]
1404 }
1405 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1406 &mut []
1407 }
1408 fn is_interactive(&self) -> bool {
1409 true
1410 }
1411 fn is_focusable(&self) -> bool {
1412 self.focusable
1413 }
1414 fn accessible_name(&self) -> Option<&str> {
1415 self.accessible_name.as_deref()
1416 }
1417 fn accessible_role(&self) -> AccessibleRole {
1418 AccessibleRole::Button
1419 }
1420 }
1421
1422 #[test]
1423 fn test_a11y_passing() {
1424 let widget = MockButton::new().with_name("Submit");
1425 let report = A11yChecker::check(&widget);
1426 assert!(report.is_passing());
1427 }
1428
1429 #[test]
1430 fn test_a11y_missing_name() {
1431 let widget = MockButton::new();
1432 let report = A11yChecker::check(&widget);
1433 assert!(!report.is_passing());
1434 assert_eq!(report.violations.len(), 1);
1435 assert_eq!(report.violations[0].rule, "aria-label");
1436 }
1437
1438 #[test]
1439 fn test_a11y_not_focusable() {
1440 let widget = MockButton::new().with_name("OK").not_focusable();
1441 let report = A11yChecker::check(&widget);
1442 assert!(!report.is_passing());
1443 assert!(report.violations.iter().any(|v| v.rule == "keyboard"));
1444 }
1445
1446 #[test]
1447 fn test_contrast_black_white() {
1448 let result = A11yChecker::check_contrast(&Color::BLACK, &Color::WHITE, false);
1449 assert!(result.passes_aa);
1450 assert!(result.passes_aaa);
1451 assert!((result.ratio - 21.0).abs() < 0.5);
1452 }
1453
1454 #[test]
1455 fn test_contrast_low() {
1456 let light_gray = Color::rgb(0.7, 0.7, 0.7);
1457 let white = Color::WHITE;
1458 let result = A11yChecker::check_contrast(&light_gray, &white, false);
1459 assert!(!result.passes_aa);
1460 }
1461
1462 #[test]
1463 fn test_contrast_large_text_threshold() {
1464 let gray = Color::rgb(0.5, 0.5, 0.5);
1466 let white = Color::WHITE;
1467
1468 let normal = A11yChecker::check_contrast(&gray, &white, false);
1469 let large = A11yChecker::check_contrast(&gray, &white, true);
1470
1471 assert!(large.passes_aa || large.ratio > normal.ratio - 1.0);
1473 }
1474
1475 #[test]
1476 fn test_report_critical() {
1477 let widget = MockButton::new().not_focusable();
1478 let report = A11yChecker::check(&widget);
1479 let critical = report.critical();
1480 assert!(!critical.is_empty());
1481 }
1482
1483 #[test]
1484 #[should_panic(expected = "Accessibility check failed")]
1485 fn test_assert_pass_fails() {
1486 let widget = MockButton::new();
1487 let report = A11yChecker::check(&widget);
1488 report.assert_pass();
1489 }
1490
1491 #[test]
1496 fn test_aria_attributes_new() {
1497 let attrs = AriaAttributes::new();
1498 assert!(attrs.role.is_none());
1499 assert!(attrs.label.is_none());
1500 assert!(!attrs.disabled);
1501 }
1502
1503 #[test]
1504 fn test_aria_attributes_with_role() {
1505 let attrs = AriaAttributes::new().with_role("button");
1506 assert_eq!(attrs.role, Some("button".to_string()));
1507 }
1508
1509 #[test]
1510 fn test_aria_attributes_with_label() {
1511 let attrs = AriaAttributes::new().with_label("Submit form");
1512 assert_eq!(attrs.label, Some("Submit form".to_string()));
1513 }
1514
1515 #[test]
1516 fn test_aria_attributes_with_expanded() {
1517 let attrs = AriaAttributes::new().with_expanded(true);
1518 assert_eq!(attrs.expanded, Some(true));
1519 }
1520
1521 #[test]
1522 fn test_aria_attributes_with_checked() {
1523 let attrs = AriaAttributes::new().with_checked(AriaChecked::True);
1524 assert_eq!(attrs.checked, Some(AriaChecked::True));
1525 }
1526
1527 #[test]
1528 fn test_aria_attributes_with_disabled() {
1529 let attrs = AriaAttributes::new().with_disabled(true);
1530 assert!(attrs.disabled);
1531 }
1532
1533 #[test]
1534 fn test_aria_attributes_with_value() {
1535 let attrs = AriaAttributes::new()
1536 .with_value_min(0.0)
1537 .with_value_max(100.0)
1538 .with_value_now(50.0);
1539 assert_eq!(attrs.value_min, Some(0.0));
1540 assert_eq!(attrs.value_max, Some(100.0));
1541 assert_eq!(attrs.value_now, Some(50.0));
1542 }
1543
1544 #[test]
1545 fn test_aria_attributes_with_live() {
1546 let attrs = AriaAttributes::new().with_live(AriaLive::Polite);
1547 assert_eq!(attrs.live, Some(AriaLive::Polite));
1548 }
1549
1550 #[test]
1551 fn test_aria_attributes_with_busy() {
1552 let attrs = AriaAttributes::new().with_busy(true);
1553 assert!(attrs.busy);
1554 }
1555
1556 #[test]
1557 fn test_aria_attributes_with_atomic() {
1558 let attrs = AriaAttributes::new().with_atomic(true);
1559 assert!(attrs.atomic);
1560 }
1561
1562 #[test]
1563 fn test_aria_attributes_with_has_popup() {
1564 let attrs = AriaAttributes::new().with_has_popup("menu");
1565 assert_eq!(attrs.has_popup, Some("menu".to_string()));
1566 }
1567
1568 #[test]
1569 fn test_aria_attributes_with_controls() {
1570 let attrs = AriaAttributes::new().with_controls("panel-1");
1571 assert_eq!(attrs.controls, Some("panel-1".to_string()));
1572 }
1573
1574 #[test]
1575 fn test_aria_attributes_with_described_by() {
1576 let attrs = AriaAttributes::new().with_described_by("desc-1");
1577 assert_eq!(attrs.described_by, Some("desc-1".to_string()));
1578 }
1579
1580 #[test]
1581 fn test_aria_attributes_with_hidden() {
1582 let attrs = AriaAttributes::new().with_hidden(true);
1583 assert!(attrs.hidden);
1584 }
1585
1586 #[test]
1587 fn test_aria_attributes_with_pressed() {
1588 let attrs = AriaAttributes::new().with_pressed(AriaChecked::Mixed);
1589 assert_eq!(attrs.pressed, Some(AriaChecked::Mixed));
1590 }
1591
1592 #[test]
1593 fn test_aria_attributes_with_selected() {
1594 let attrs = AriaAttributes::new().with_selected(true);
1595 assert_eq!(attrs.selected, Some(true));
1596 }
1597
1598 #[test]
1599 fn test_aria_attributes_with_level() {
1600 let attrs = AriaAttributes::new().with_level(2);
1601 assert_eq!(attrs.level, Some(2));
1602 }
1603
1604 #[test]
1605 fn test_aria_attributes_chained_builder() {
1606 let attrs = AriaAttributes::new()
1607 .with_role("checkbox")
1608 .with_label("Accept terms")
1609 .with_checked(AriaChecked::False)
1610 .with_disabled(false);
1611
1612 assert_eq!(attrs.role, Some("checkbox".to_string()));
1613 assert_eq!(attrs.label, Some("Accept terms".to_string()));
1614 assert_eq!(attrs.checked, Some(AriaChecked::False));
1615 assert!(!attrs.disabled);
1616 }
1617
1618 #[test]
1623 fn test_aria_checked_as_str() {
1624 assert_eq!(AriaChecked::True.as_str(), "true");
1625 assert_eq!(AriaChecked::False.as_str(), "false");
1626 assert_eq!(AriaChecked::Mixed.as_str(), "mixed");
1627 }
1628
1629 #[test]
1634 fn test_aria_live_as_str() {
1635 assert_eq!(AriaLive::Off.as_str(), "off");
1636 assert_eq!(AriaLive::Polite.as_str(), "polite");
1637 assert_eq!(AriaLive::Assertive.as_str(), "assertive");
1638 }
1639
1640 #[test]
1645 fn test_to_html_attrs_empty() {
1646 let attrs = AriaAttributes::new();
1647 let html_attrs = attrs.to_html_attrs();
1648 assert!(html_attrs.is_empty());
1649 }
1650
1651 #[test]
1652 fn test_to_html_attrs_role() {
1653 let attrs = AriaAttributes::new().with_role("button");
1654 let html_attrs = attrs.to_html_attrs();
1655 assert_eq!(html_attrs.len(), 1);
1656 assert_eq!(html_attrs[0], ("role".to_string(), "button".to_string()));
1657 }
1658
1659 #[test]
1660 fn test_to_html_attrs_label() {
1661 let attrs = AriaAttributes::new().with_label("Submit");
1662 let html_attrs = attrs.to_html_attrs();
1663 assert_eq!(html_attrs.len(), 1);
1664 assert_eq!(
1665 html_attrs[0],
1666 ("aria-label".to_string(), "Submit".to_string())
1667 );
1668 }
1669
1670 #[test]
1671 fn test_to_html_attrs_disabled() {
1672 let attrs = AriaAttributes::new().with_disabled(true);
1673 let html_attrs = attrs.to_html_attrs();
1674 assert_eq!(html_attrs.len(), 1);
1675 assert_eq!(
1676 html_attrs[0],
1677 ("aria-disabled".to_string(), "true".to_string())
1678 );
1679 }
1680
1681 #[test]
1682 fn test_to_html_attrs_checked() {
1683 let attrs = AriaAttributes::new().with_checked(AriaChecked::Mixed);
1684 let html_attrs = attrs.to_html_attrs();
1685 assert_eq!(html_attrs.len(), 1);
1686 assert_eq!(
1687 html_attrs[0],
1688 ("aria-checked".to_string(), "mixed".to_string())
1689 );
1690 }
1691
1692 #[test]
1693 fn test_to_html_attrs_expanded() {
1694 let attrs = AriaAttributes::new().with_expanded(false);
1695 let html_attrs = attrs.to_html_attrs();
1696 assert_eq!(html_attrs.len(), 1);
1697 assert_eq!(
1698 html_attrs[0],
1699 ("aria-expanded".to_string(), "false".to_string())
1700 );
1701 }
1702
1703 #[test]
1704 fn test_to_html_attrs_value_range() {
1705 let attrs = AriaAttributes::new()
1706 .with_value_now(50.0)
1707 .with_value_min(0.0)
1708 .with_value_max(100.0);
1709 let html_attrs = attrs.to_html_attrs();
1710 assert_eq!(html_attrs.len(), 3);
1711 assert!(html_attrs.contains(&("aria-valuenow".to_string(), "50".to_string())));
1712 assert!(html_attrs.contains(&("aria-valuemin".to_string(), "0".to_string())));
1713 assert!(html_attrs.contains(&("aria-valuemax".to_string(), "100".to_string())));
1714 }
1715
1716 #[test]
1717 fn test_to_html_attrs_live() {
1718 let attrs = AriaAttributes::new().with_live(AriaLive::Assertive);
1719 let html_attrs = attrs.to_html_attrs();
1720 assert_eq!(html_attrs.len(), 1);
1721 assert_eq!(
1722 html_attrs[0],
1723 ("aria-live".to_string(), "assertive".to_string())
1724 );
1725 }
1726
1727 #[test]
1728 fn test_to_html_attrs_hidden() {
1729 let attrs = AriaAttributes::new().with_hidden(true);
1730 let html_attrs = attrs.to_html_attrs();
1731 assert_eq!(html_attrs.len(), 1);
1732 assert_eq!(
1733 html_attrs[0],
1734 ("aria-hidden".to_string(), "true".to_string())
1735 );
1736 }
1737
1738 #[test]
1739 fn test_to_html_attrs_multiple() {
1740 let attrs = AriaAttributes::new()
1741 .with_role("slider")
1742 .with_label("Volume")
1743 .with_value_now(75.0)
1744 .with_value_min(0.0)
1745 .with_value_max(100.0);
1746 let html_attrs = attrs.to_html_attrs();
1747 assert_eq!(html_attrs.len(), 5);
1748 }
1749
1750 #[test]
1755 fn test_to_html_string_empty() {
1756 let attrs = AriaAttributes::new();
1757 let html = attrs.to_html_string();
1758 assert_eq!(html, "");
1759 }
1760
1761 #[test]
1762 fn test_to_html_string_single() {
1763 let attrs = AriaAttributes::new().with_role("button");
1764 let html = attrs.to_html_string();
1765 assert_eq!(html, "role=\"button\"");
1766 }
1767
1768 #[test]
1769 fn test_to_html_string_multiple() {
1770 let attrs = AriaAttributes::new()
1771 .with_role("checkbox")
1772 .with_checked(AriaChecked::True);
1773 let html = attrs.to_html_string();
1774 assert!(html.contains("role=\"checkbox\""));
1775 assert!(html.contains("aria-checked=\"true\""));
1776 }
1777
1778 #[test]
1779 fn test_to_html_string_escapes_quotes() {
1780 let attrs = AriaAttributes::new().with_label("Click \"here\"");
1781 let html = attrs.to_html_string();
1782 assert!(html.contains("aria-label=\"Click "here"\""));
1783 }
1784
1785 #[test]
1790 fn test_aria_from_widget_button() {
1791 let widget = MockButton::new().with_name("Submit");
1792 let attrs = aria_from_widget(&widget);
1793 assert_eq!(attrs.role, Some("button".to_string()));
1794 assert_eq!(attrs.label, Some("Submit".to_string()));
1795 assert!(!attrs.disabled);
1796 }
1797
1798 #[test]
1799 fn test_aria_from_widget_no_name() {
1800 let widget = MockButton::new();
1801 let attrs = aria_from_widget(&widget);
1802 assert_eq!(attrs.role, Some("button".to_string()));
1803 assert!(attrs.label.is_none());
1804 }
1805
1806 struct MockLabel {
1808 text: String,
1809 }
1810
1811 impl MockLabel {
1812 fn new(text: &str) -> Self {
1813 Self {
1814 text: text.to_string(),
1815 }
1816 }
1817 }
1818
1819 impl Widget for MockLabel {
1820 fn type_id(&self) -> TypeId {
1821 TypeId::of::<Self>()
1822 }
1823 fn measure(&self, c: Constraints) -> Size {
1824 c.smallest()
1825 }
1826 fn layout(&mut self, b: Rect) -> LayoutResult {
1827 LayoutResult { size: b.size() }
1828 }
1829 fn paint(&self, _: &mut dyn Canvas) {}
1830 fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1831 None
1832 }
1833 fn children(&self) -> &[Box<dyn Widget>] {
1834 &[]
1835 }
1836 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1837 &mut []
1838 }
1839 fn is_interactive(&self) -> bool {
1840 false
1841 }
1842 fn is_focusable(&self) -> bool {
1843 false
1844 }
1845 fn accessible_name(&self) -> Option<&str> {
1846 Some(&self.text)
1847 }
1848 fn accessible_role(&self) -> AccessibleRole {
1849 AccessibleRole::Heading
1850 }
1851 }
1852
1853 #[test]
1854 fn test_aria_from_widget_non_interactive() {
1855 let widget = MockLabel::new("Welcome");
1856 let attrs = aria_from_widget(&widget);
1857 assert_eq!(attrs.role, Some("heading".to_string()));
1858 assert_eq!(attrs.label, Some("Welcome".to_string()));
1859 assert!(attrs.disabled);
1860 }
1861
1862 struct MockGenericWidget;
1864
1865 impl Widget for MockGenericWidget {
1866 fn type_id(&self) -> TypeId {
1867 TypeId::of::<Self>()
1868 }
1869 fn measure(&self, c: Constraints) -> Size {
1870 c.smallest()
1871 }
1872 fn layout(&mut self, b: Rect) -> LayoutResult {
1873 LayoutResult { size: b.size() }
1874 }
1875 fn paint(&self, _: &mut dyn Canvas) {}
1876 fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
1877 None
1878 }
1879 fn children(&self) -> &[Box<dyn Widget>] {
1880 &[]
1881 }
1882 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
1883 &mut []
1884 }
1885 fn is_interactive(&self) -> bool {
1886 false
1887 }
1888 fn is_focusable(&self) -> bool {
1889 false
1890 }
1891 fn accessible_name(&self) -> Option<&str> {
1892 None
1893 }
1894 fn accessible_role(&self) -> AccessibleRole {
1895 AccessibleRole::Generic
1896 }
1897 }
1898
1899 #[test]
1900 fn test_aria_from_widget_generic() {
1901 let widget = MockGenericWidget;
1902 let attrs = aria_from_widget(&widget);
1903 assert!(attrs.role.is_none());
1904 assert!(attrs.label.is_none());
1905 assert!(!attrs.disabled);
1907 }
1908
1909 #[test]
1914 fn test_a11y_config_default() {
1915 let config = A11yConfig::default();
1916 assert!(config.check_touch_targets);
1917 assert!(config.check_heading_hierarchy);
1918 assert!(!config.check_focus_indicators);
1919 assert!((config.min_contrast_normal - 4.5).abs() < 0.01);
1920 }
1921
1922 #[test]
1923 fn test_a11y_config_strict() {
1924 let config = A11yConfig::strict();
1925 assert!(config.check_touch_targets);
1926 assert!(config.check_heading_hierarchy);
1927 assert!(config.check_focus_indicators);
1928 assert!((config.min_contrast_normal - 7.0).abs() < 0.01);
1929 }
1930
1931 #[test]
1932 fn test_a11y_config_mobile() {
1933 let config = A11yConfig::mobile();
1934 assert!(config.check_touch_targets);
1935 assert!(config.check_heading_hierarchy);
1936 assert!(!config.check_focus_indicators);
1937 }
1938
1939 #[test]
1944 fn test_min_touch_target_size() {
1945 assert_eq!(MIN_TOUCH_TARGET_SIZE, 44.0);
1946 }
1947
1948 #[test]
1949 fn test_min_focus_indicator_area() {
1950 assert_eq!(MIN_FOCUS_INDICATOR_AREA, 2.0);
1951 }
1952
1953 #[test]
1958 fn test_check_with_config() {
1959 let widget = MockButton::new().with_name("OK");
1960 let config = A11yConfig {
1962 check_touch_targets: false,
1963 check_heading_hierarchy: true,
1964 check_focus_indicators: false,
1965 min_contrast_normal: 4.5,
1966 min_contrast_large: 3.0,
1967 };
1968 let report = A11yChecker::check_with_config(&widget, &config);
1969 assert!(report.is_passing());
1970 }
1971
1972 struct MockImage {
1977 alt_text: Option<String>,
1978 }
1979
1980 impl MockImage {
1981 fn new() -> Self {
1982 Self { alt_text: None }
1983 }
1984
1985 fn with_alt(mut self, alt: &str) -> Self {
1986 self.alt_text = Some(alt.to_string());
1987 self
1988 }
1989 }
1990
1991 impl Widget for MockImage {
1992 fn type_id(&self) -> TypeId {
1993 TypeId::of::<Self>()
1994 }
1995 fn measure(&self, c: Constraints) -> Size {
1996 c.smallest()
1997 }
1998 fn layout(&mut self, b: Rect) -> LayoutResult {
1999 LayoutResult { size: b.size() }
2000 }
2001 fn paint(&self, _: &mut dyn Canvas) {}
2002 fn event(&mut self, _: &Event) -> Option<Box<dyn Any + Send>> {
2003 None
2004 }
2005 fn children(&self) -> &[Box<dyn Widget>] {
2006 &[]
2007 }
2008 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
2009 &mut []
2010 }
2011 fn is_interactive(&self) -> bool {
2012 false
2013 }
2014 fn is_focusable(&self) -> bool {
2015 false
2016 }
2017 fn accessible_name(&self) -> Option<&str> {
2018 self.alt_text.as_deref()
2019 }
2020 fn accessible_role(&self) -> AccessibleRole {
2021 AccessibleRole::Image
2022 }
2023 }
2024
2025 #[test]
2026 fn test_image_missing_alt() {
2027 let widget = MockImage::new();
2028 let report = A11yChecker::check(&widget);
2029 assert!(!report.is_passing());
2030 assert!(report.violations.iter().any(|v| v.rule == "image-alt"));
2031 }
2032
2033 #[test]
2034 fn test_image_with_alt() {
2035 let widget = MockImage::new().with_alt("A sunset over the ocean");
2036 let report = A11yChecker::check(&widget);
2037 assert!(!report.violations.iter().any(|v| v.rule == "image-alt"));
2039 }
2040
2041 #[test]
2046 fn test_impact_equality() {
2047 assert_eq!(Impact::Minor, Impact::Minor);
2048 assert_eq!(Impact::Moderate, Impact::Moderate);
2049 assert_eq!(Impact::Serious, Impact::Serious);
2050 assert_eq!(Impact::Critical, Impact::Critical);
2051 assert_ne!(Impact::Minor, Impact::Critical);
2052 }
2053
2054 #[test]
2059 fn test_form_field_passing() {
2060 let form = FormAccessibility::new().with_name("Login Form").field(
2061 FormFieldA11y::new("email")
2062 .with_label("Email Address")
2063 .with_type(InputType::Email)
2064 .with_autocomplete(AutocompleteValue::Email),
2065 );
2066
2067 let report = FormA11yChecker::check(&form);
2068 assert!(report.is_passing());
2069 }
2070
2071 #[test]
2072 fn test_form_field_missing_label() {
2073 let form = FormAccessibility::new()
2074 .with_name("Test Form")
2075 .field(FormFieldA11y::new("email").with_type(InputType::Email));
2076
2077 let report = FormA11yChecker::check(&form);
2078 assert!(!report.is_passing());
2079 assert!(report
2080 .violations
2081 .iter()
2082 .any(|v| v.rule == FormA11yRule::MissingLabel));
2083 }
2084
2085 #[test]
2086 fn test_form_field_aria_label_counts() {
2087 let form = FormAccessibility::new().with_name("Test Form").field(
2088 FormFieldA11y::new("search")
2089 .with_type(InputType::Search)
2090 .with_aria_label("Search products"),
2091 );
2092
2093 let report = FormA11yChecker::check(&form);
2094 assert!(!report
2096 .violations
2097 .iter()
2098 .any(|v| v.rule == FormA11yRule::MissingLabel));
2099 }
2100
2101 #[test]
2102 fn test_form_required_missing_aria() {
2103 let form = FormAccessibility::new().with_name("Test Form").field(
2104 FormFieldA11y::new("name")
2105 .with_label("Full Name")
2106 .with_required(true, false), );
2108
2109 let report = FormA11yChecker::check(&form);
2110 assert!(report
2111 .violations
2112 .iter()
2113 .any(|v| v.rule == FormA11yRule::MissingRequiredIndicator));
2114 }
2115
2116 #[test]
2117 fn test_form_required_missing_visual() {
2118 let form = FormAccessibility::new().with_name("Test Form").field({
2119 let mut field = FormFieldA11y::new("name").with_label("Full Name");
2120 field.required = true;
2121 field.aria_required = true;
2122 field.has_visual_required_indicator = false;
2123 field
2124 });
2125
2126 let report = FormA11yChecker::check(&form);
2127 assert!(report
2128 .violations
2129 .iter()
2130 .any(|v| v.rule == FormA11yRule::MissingVisualRequired));
2131 }
2132
2133 #[test]
2134 fn test_form_required_proper() {
2135 let form = FormAccessibility::new().with_name("Test Form").field(
2136 FormFieldA11y::new("name")
2137 .with_label("Full Name")
2138 .required(), );
2140
2141 let report = FormA11yChecker::check(&form);
2142 assert!(!report.violations.iter().any(|v| matches!(
2144 v.rule,
2145 FormA11yRule::MissingRequiredIndicator | FormA11yRule::MissingVisualRequired
2146 )));
2147 }
2148
2149 #[test]
2150 fn test_form_error_without_aria_invalid() {
2151 let form = FormAccessibility::new().with_name("Test Form").field({
2152 let mut field = FormFieldA11y::new("email")
2153 .with_label("Email")
2154 .with_type(InputType::Email);
2155 field.has_error = true;
2156 field.aria_invalid = false;
2157 field.error_message = Some("Invalid email".to_string());
2158 field
2159 });
2160
2161 let report = FormA11yChecker::check(&form);
2162 assert!(report
2163 .violations
2164 .iter()
2165 .any(|v| v.rule == FormA11yRule::MissingErrorState));
2166 }
2167
2168 #[test]
2169 fn test_form_error_without_message() {
2170 let form = FormAccessibility::new().with_name("Test Form").field({
2171 let mut field = FormFieldA11y::new("email")
2172 .with_label("Email")
2173 .with_type(InputType::Email);
2174 field.has_error = true;
2175 field.aria_invalid = true;
2176 field
2178 });
2179
2180 let report = FormA11yChecker::check(&form);
2181 assert!(report
2182 .violations
2183 .iter()
2184 .any(|v| v.rule == FormA11yRule::MissingErrorMessage));
2185 }
2186
2187 #[test]
2188 fn test_form_error_not_associated() {
2189 let form = FormAccessibility::new().with_name("Test Form").field({
2190 let mut field = FormFieldA11y::new("email")
2191 .with_label("Email")
2192 .with_type(InputType::Email);
2193 field.has_error = true;
2194 field.aria_invalid = true;
2195 field.error_message = Some("Invalid email".to_string());
2196 field
2198 });
2199
2200 let report = FormA11yChecker::check(&form);
2201 assert!(report
2202 .violations
2203 .iter()
2204 .any(|v| v.rule == FormA11yRule::ErrorNotAssociated));
2205 }
2206
2207 #[test]
2208 fn test_form_error_properly_associated() {
2209 let form = FormAccessibility::new().with_name("Test Form").field(
2210 FormFieldA11y::new("email")
2211 .with_label("Email")
2212 .with_type(InputType::Email)
2213 .with_autocomplete(AutocompleteValue::Email)
2214 .with_error("Please enter a valid email address", true),
2215 );
2216
2217 let report = FormA11yChecker::check(&form);
2218 assert!(!report.violations.iter().any(|v| matches!(
2220 v.rule,
2221 FormA11yRule::MissingErrorState
2222 | FormA11yRule::MissingErrorMessage
2223 | FormA11yRule::ErrorNotAssociated
2224 )));
2225 }
2226
2227 #[test]
2228 fn test_form_missing_autocomplete() {
2229 let form = FormAccessibility::new().with_name("Test Form").field(
2230 FormFieldA11y::new("email")
2231 .with_label("Email")
2232 .with_type(InputType::Email),
2233 );
2235
2236 let report = FormA11yChecker::check(&form);
2237 assert!(report
2238 .violations
2239 .iter()
2240 .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2241 }
2242
2243 #[test]
2244 fn test_form_autocomplete_not_needed_for_checkbox() {
2245 let form = FormAccessibility::new().with_name("Test Form").field(
2246 FormFieldA11y::new("terms")
2247 .with_label("I agree to terms")
2248 .with_type(InputType::Checkbox),
2249 );
2250
2251 let report = FormA11yChecker::check(&form);
2252 assert!(!report
2254 .violations
2255 .iter()
2256 .any(|v| v.rule == FormA11yRule::MissingAutocomplete));
2257 }
2258
2259 #[test]
2260 fn test_form_placeholder_as_label() {
2261 let form = FormAccessibility::new().with_name("Test Form").field(
2262 FormFieldA11y::new("email")
2263 .with_type(InputType::Email)
2264 .with_placeholder("Enter your email"),
2265 );
2266
2267 let report = FormA11yChecker::check(&form);
2268 assert!(report
2269 .violations
2270 .iter()
2271 .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2272 }
2273
2274 #[test]
2275 fn test_form_placeholder_with_label_ok() {
2276 let form = FormAccessibility::new().with_name("Test Form").field(
2277 FormFieldA11y::new("email")
2278 .with_label("Email")
2279 .with_type(InputType::Email)
2280 .with_autocomplete(AutocompleteValue::Email)
2281 .with_placeholder("e.g., user@example.com"),
2282 );
2283
2284 let report = FormA11yChecker::check(&form);
2285 assert!(!report
2287 .violations
2288 .iter()
2289 .any(|v| v.rule == FormA11yRule::PlaceholderAsLabel));
2290 }
2291
2292 #[test]
2293 fn test_form_radio_buttons_not_grouped() {
2294 let form = FormAccessibility::new()
2295 .with_name("Test Form")
2296 .field(
2297 FormFieldA11y::new("option1")
2298 .with_label("Option 1")
2299 .with_type(InputType::Radio),
2300 )
2301 .field(
2302 FormFieldA11y::new("option2")
2303 .with_label("Option 2")
2304 .with_type(InputType::Radio),
2305 );
2306
2307 let report = FormA11yChecker::check(&form);
2308 assert!(report
2309 .violations
2310 .iter()
2311 .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2312 }
2313
2314 #[test]
2315 fn test_form_radio_buttons_properly_grouped() {
2316 let form = FormAccessibility::new()
2317 .with_name("Test Form")
2318 .field(
2319 FormFieldA11y::new("option1")
2320 .with_label("Option 1")
2321 .with_type(InputType::Radio),
2322 )
2323 .field(
2324 FormFieldA11y::new("option2")
2325 .with_label("Option 2")
2326 .with_type(InputType::Radio),
2327 )
2328 .group(
2329 FormFieldGroup::new("options")
2330 .with_legend("Choose an option")
2331 .with_field("option1")
2332 .with_field("option2"),
2333 );
2334
2335 let report = FormA11yChecker::check(&form);
2336 assert!(!report
2337 .violations
2338 .iter()
2339 .any(|v| v.rule == FormA11yRule::RelatedFieldsNotGrouped));
2340 }
2341
2342 #[test]
2343 fn test_form_group_missing_legend() {
2344 let form = FormAccessibility::new().with_name("Test Form").group(
2345 FormFieldGroup::new("address")
2346 .with_field("street")
2347 .with_field("city"),
2348 );
2349
2350 let report = FormA11yChecker::check(&form);
2351 assert!(report
2352 .violations
2353 .iter()
2354 .any(|v| v.rule == FormA11yRule::GroupMissingLegend));
2355 }
2356
2357 #[test]
2358 fn test_form_missing_accessible_name() {
2359 let form = FormAccessibility::new().field(
2360 FormFieldA11y::new("email")
2361 .with_label("Email")
2362 .with_type(InputType::Email)
2363 .with_autocomplete(AutocompleteValue::Email),
2364 );
2365
2366 let report = FormA11yChecker::check(&form);
2367 assert!(report
2368 .violations
2369 .iter()
2370 .any(|v| v.rule == FormA11yRule::FormMissingName));
2371 }
2372
2373 #[test]
2374 fn test_form_report_violations_for_field() {
2375 let form = FormAccessibility::new()
2376 .with_name("Test Form")
2377 .field(FormFieldA11y::new("bad_field").with_type(InputType::Email))
2378 .field(
2379 FormFieldA11y::new("good_field")
2380 .with_label("Good Field")
2381 .with_type(InputType::Text),
2382 );
2383
2384 let report = FormA11yChecker::check(&form);
2385 let bad_violations = report.violations_for_field("bad_field");
2386 assert!(!bad_violations.is_empty());
2387
2388 let good_violations = report.violations_for_field("good_field");
2389 assert!(good_violations.len() <= 1);
2391 }
2392
2393 #[test]
2394 fn test_form_report_is_acceptable() {
2395 let form = FormAccessibility::new().with_name("Test Form").field(
2397 FormFieldA11y::new("email")
2398 .with_label("Email")
2399 .with_type(InputType::Email),
2400 );
2402
2403 let report = FormA11yChecker::check(&form);
2404 assert!(report.is_acceptable()); }
2406
2407 #[test]
2408 fn test_input_type_should_have_autocomplete() {
2409 assert!(InputType::Email.should_have_autocomplete());
2410 assert!(InputType::Password.should_have_autocomplete());
2411 assert!(InputType::Tel.should_have_autocomplete());
2412 assert!(InputType::Text.should_have_autocomplete());
2413 assert!(!InputType::Checkbox.should_have_autocomplete());
2414 assert!(!InputType::Radio.should_have_autocomplete());
2415 assert!(!InputType::Date.should_have_autocomplete());
2416 }
2417
2418 #[test]
2419 fn test_autocomplete_value_as_str() {
2420 assert_eq!(AutocompleteValue::Email.as_str(), "email");
2421 assert_eq!(AutocompleteValue::GivenName.as_str(), "given-name");
2422 assert_eq!(
2423 AutocompleteValue::CurrentPassword.as_str(),
2424 "current-password"
2425 );
2426 assert_eq!(AutocompleteValue::Off.as_str(), "off");
2427 }
2428
2429 #[test]
2430 fn test_form_a11y_rule_name() {
2431 assert_eq!(FormA11yRule::MissingLabel.name(), "missing-label");
2432 assert_eq!(
2433 FormA11yRule::MissingRequiredIndicator.name(),
2434 "missing-required-indicator"
2435 );
2436 assert_eq!(
2437 FormA11yRule::PlaceholderAsLabel.name(),
2438 "placeholder-as-label"
2439 );
2440 }
2441
2442 #[test]
2443 fn test_form_violations_for_rule() {
2444 let form = FormAccessibility::new()
2445 .with_name("Test Form")
2446 .field(FormFieldA11y::new("field1").with_type(InputType::Email))
2447 .field(FormFieldA11y::new("field2").with_type(InputType::Email));
2448
2449 let report = FormA11yChecker::check(&form);
2450 let missing_labels = report.violations_for_rule(FormA11yRule::MissingLabel);
2451 assert_eq!(missing_labels.len(), 2); }
2453
2454 #[test]
2455 fn test_form_complete_signup_form() {
2456 let form = FormAccessibility::new()
2458 .with_name("Create Account")
2459 .field(
2460 FormFieldA11y::new("first_name")
2461 .with_label("First Name")
2462 .with_type(InputType::Text)
2463 .with_autocomplete(AutocompleteValue::GivenName)
2464 .required(),
2465 )
2466 .field(
2467 FormFieldA11y::new("last_name")
2468 .with_label("Last Name")
2469 .with_type(InputType::Text)
2470 .with_autocomplete(AutocompleteValue::FamilyName)
2471 .required(),
2472 )
2473 .field(
2474 FormFieldA11y::new("email")
2475 .with_label("Email Address")
2476 .with_type(InputType::Email)
2477 .with_autocomplete(AutocompleteValue::Email)
2478 .required(),
2479 )
2480 .field(
2481 FormFieldA11y::new("password")
2482 .with_label("Password")
2483 .with_type(InputType::Password)
2484 .with_autocomplete(AutocompleteValue::NewPassword)
2485 .required(),
2486 )
2487 .field(
2488 FormFieldA11y::new("terms")
2489 .with_label("I agree to the Terms of Service")
2490 .with_type(InputType::Checkbox)
2491 .required(),
2492 );
2493
2494 let report = FormA11yChecker::check(&form);
2495 assert!(
2496 report.is_passing(),
2497 "Complete signup form should pass: {:?}",
2498 report.violations
2499 );
2500 }
2501}