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}