Skip to main content

presentar_test/
a11y.rs

1//! Accessibility checking for WCAG 2.1 compliance.
2//!
3//! Implements comprehensive WCAG 2.1 AA checks including:
4//! - Color contrast (1.4.3, 1.4.6)
5//! - Keyboard accessibility (2.1.1)
6//! - Focus indicators (2.4.7)
7//! - Touch target size (2.5.5)
8//! - Name/role/value (4.1.2)
9//! - Heading hierarchy (1.3.1)
10
11use presentar_core::widget::AccessibleRole;
12use presentar_core::{Color, Widget};
13
14/// Minimum touch target size in pixels (WCAG 2.5.5)
15pub const MIN_TOUCH_TARGET_SIZE: f32 = 44.0;
16
17/// Minimum focus indicator area (WCAG 2.4.11)
18pub const MIN_FOCUS_INDICATOR_AREA: f32 = 2.0;
19
20/// Accessibility checker.
21pub struct A11yChecker;
22
23impl A11yChecker {
24    /// Check a widget tree for accessibility violations.
25    #[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    /// Check with custom configuration.
34    #[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        // Check for missing accessible name on interactive elements (WCAG 4.1.2)
53        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        // Check for keyboard focusable elements (WCAG 2.1.1)
63        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        // Check touch target size (WCAG 2.5.5)
73        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        // Check heading hierarchy (WCAG 1.3.1)
89        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        // Check focus indicator visibility (WCAG 2.4.7)
110        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        // Check for images without alt text (WCAG 1.1.1)
122        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        // Recurse into children
132        for child in widget.children() {
133            Self::check_widget(child.as_ref(), violations, context);
134        }
135    }
136
137    /// Extract heading level from widget (if it's a heading)
138    fn heading_level(widget: &dyn Widget) -> Option<u8> {
139        // Check if the accessible name contains heading level info
140        // Or use aria-level if available
141        if let Some(name) = widget.accessible_name() {
142            // Try to extract from pattern like "Heading Level 2" or "h2"
143            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        // Default to level 2 if we can't determine
152        Some(2)
153    }
154
155    /// Check if widget has a visible focus indicator
156    fn has_visible_focus_indicator(widget: &dyn Widget) -> bool {
157        // For now, assume all focusable widgets have focus indicators
158        // In a real implementation, we'd check for focus ring styles
159        widget.is_focusable()
160    }
161
162    /// Check contrast ratio between foreground and background colors.
163    #[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        // WCAG 2.1 thresholds
172        let (aa_threshold, aaa_threshold) = if large_text {
173            (3.0, 4.5) // Large text (14pt bold or 18pt regular)
174        } else {
175            (4.5, 7.0) // Normal text
176        };
177
178        ContrastResult {
179            ratio,
180            passes_aa: ratio >= aa_threshold,
181            passes_aaa: ratio >= aaa_threshold,
182        }
183    }
184}
185
186/// Accessibility report.
187#[derive(Debug)]
188pub struct A11yReport {
189    /// List of violations found
190    pub violations: Vec<A11yViolation>,
191}
192
193impl A11yReport {
194    /// Check if all accessibility tests passed.
195    #[must_use]
196    pub fn is_passing(&self) -> bool {
197        self.violations.is_empty()
198    }
199
200    /// Get critical violations only.
201    #[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    /// Assert that all accessibility tests pass.
210    ///
211    /// # Panics
212    ///
213    /// Panics if there are any violations.
214    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/// A single accessibility violation.
237#[derive(Debug, Clone)]
238pub struct A11yViolation {
239    /// Rule that was violated
240    pub rule: String,
241    /// Human-readable message
242    pub message: String,
243    /// WCAG success criterion
244    pub wcag: String,
245    /// Impact level
246    pub impact: Impact,
247}
248
249/// Impact level of an accessibility violation.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Impact {
252    /// Minor issue
253    Minor,
254    /// Moderate issue
255    Moderate,
256    /// Serious issue
257    Serious,
258    /// Critical issue - must fix
259    Critical,
260}
261
262/// Configuration for accessibility checks.
263#[derive(Debug, Clone)]
264pub struct A11yConfig {
265    /// Check touch target sizes (WCAG 2.5.5)
266    pub check_touch_targets: bool,
267    /// Check heading hierarchy (WCAG 1.3.1)
268    pub check_heading_hierarchy: bool,
269    /// Check focus indicators (WCAG 2.4.7)
270    pub check_focus_indicators: bool,
271    /// Minimum contrast ratio for normal text (WCAG 1.4.3)
272    pub min_contrast_normal: f32,
273    /// Minimum contrast ratio for large text (WCAG 1.4.3)
274    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, // Disabled by default as requires style info
283            min_contrast_normal: 4.5,
284            min_contrast_large: 3.0,
285        }
286    }
287}
288
289impl A11yConfig {
290    /// Create a new config with all checks enabled.
291    #[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, // AAA level
298            min_contrast_large: 4.5,  // AAA level
299        }
300    }
301
302    /// Create a config for mobile apps.
303    #[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/// Internal context for walking the widget tree.
316#[derive(Debug, Default)]
317struct CheckContext {
318    /// Last heading level seen (for hierarchy check)
319    last_heading_level: u8,
320    /// Whether to check touch target sizes
321    check_touch_targets: bool,
322    /// Whether to check heading hierarchy
323    check_heading_hierarchy: bool,
324    /// Whether to check focus indicators
325    check_focus_indicators: bool,
326}
327
328/// Result of a contrast check.
329#[derive(Debug, Clone)]
330pub struct ContrastResult {
331    /// Calculated contrast ratio
332    pub ratio: f32,
333    /// Passes WCAG AA
334    pub passes_aa: bool,
335    /// Passes WCAG AAA
336    pub passes_aaa: bool,
337}
338
339// =============================================================================
340// ARIA Attribute Generation
341// =============================================================================
342
343/// ARIA attributes for a widget.
344#[derive(Debug, Clone, Default)]
345pub struct AriaAttributes {
346    /// The ARIA role
347    pub role: Option<String>,
348    /// Accessible label
349    pub label: Option<String>,
350    /// Accessible description
351    pub described_by: Option<String>,
352    /// Whether element is hidden from accessibility tree
353    pub hidden: bool,
354    /// Whether element is expanded (for expandable elements)
355    pub expanded: Option<bool>,
356    /// Whether element is selected
357    pub selected: Option<bool>,
358    /// Whether element is checked (for checkboxes/switches)
359    pub checked: Option<AriaChecked>,
360    /// Whether element is pressed (for toggle buttons)
361    pub pressed: Option<AriaChecked>,
362    /// Whether element is disabled
363    pub disabled: bool,
364    /// Whether element is required
365    pub required: bool,
366    /// Whether element is invalid
367    pub invalid: bool,
368    /// Current value for range widgets
369    pub value_now: Option<f64>,
370    /// Minimum value for range widgets
371    pub value_min: Option<f64>,
372    /// Maximum value for range widgets
373    pub value_max: Option<f64>,
374    /// Text representation of value
375    pub value_text: Option<String>,
376    /// Level (for headings)
377    pub level: Option<u8>,
378    /// Position in set
379    pub pos_in_set: Option<u32>,
380    /// Set size
381    pub set_size: Option<u32>,
382    /// Controls another element (ID reference)
383    pub controls: Option<String>,
384    /// Has popup indicator
385    pub has_popup: Option<String>,
386    /// Is busy/loading
387    pub busy: bool,
388    /// Live region politeness
389    pub live: Option<AriaLive>,
390    /// Atomic live region
391    pub atomic: bool,
392}
393
394/// ARIA checked state.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AriaChecked {
397    /// Checked
398    True,
399    /// Not checked
400    False,
401    /// Mixed/indeterminate
402    Mixed,
403}
404
405impl AriaChecked {
406    /// Return string representation.
407    #[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/// ARIA live region politeness.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum AriaLive {
420    /// Polite announcements
421    Polite,
422    /// Assertive announcements
423    Assertive,
424    /// No announcements
425    Off,
426}
427
428impl AriaLive {
429    /// Return string representation.
430    #[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    /// Create new empty ARIA attributes.
442    #[must_use]
443    pub fn new() -> Self {
444        Self::default()
445    }
446
447    /// Set the role.
448    #[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    /// Set the label.
455    #[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    /// Set hidden.
462    #[must_use]
463    pub const fn with_hidden(mut self, hidden: bool) -> Self {
464        self.hidden = hidden;
465        self
466    }
467
468    /// Set expanded state.
469    #[must_use]
470    pub const fn with_expanded(mut self, expanded: bool) -> Self {
471        self.expanded = Some(expanded);
472        self
473    }
474
475    /// Set selected state.
476    #[must_use]
477    pub const fn with_selected(mut self, selected: bool) -> Self {
478        self.selected = Some(selected);
479        self
480    }
481
482    /// Set checked state.
483    #[must_use]
484    pub const fn with_checked(mut self, checked: AriaChecked) -> Self {
485        self.checked = Some(checked);
486        self
487    }
488
489    /// Set pressed state (for toggle buttons).
490    #[must_use]
491    pub const fn with_pressed(mut self, pressed: AriaChecked) -> Self {
492        self.pressed = Some(pressed);
493        self
494    }
495
496    /// Set disabled state.
497    #[must_use]
498    pub const fn with_disabled(mut self, disabled: bool) -> Self {
499        self.disabled = disabled;
500        self
501    }
502
503    /// Set busy state.
504    #[must_use]
505    pub const fn with_busy(mut self, busy: bool) -> Self {
506        self.busy = busy;
507        self
508    }
509
510    /// Set atomic.
511    #[must_use]
512    pub const fn with_atomic(mut self, atomic: bool) -> Self {
513        self.atomic = atomic;
514        self
515    }
516
517    /// Set range values.
518    #[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    /// Set current value.
527    #[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    /// Set minimum value.
534    #[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    /// Set maximum value.
541    #[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    /// Set controls reference.
548    #[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    /// Set described by reference.
555    #[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    /// Set has popup.
562    #[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    /// Set heading level.
569    #[must_use]
570    pub const fn with_level(mut self, level: u8) -> Self {
571        self.level = Some(level);
572        self
573    }
574
575    /// Set position in set.
576    #[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    /// Set live region.
584    #[must_use]
585    pub const fn with_live(mut self, live: AriaLive) -> Self {
586        self.live = Some(live);
587        self
588    }
589
590    /// Generate HTML ARIA attributes.
591    #[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    /// Generate HTML attribute string.
669    #[must_use]
670    pub fn to_html_string(&self) -> String {
671        self.to_html_attrs()
672            .into_iter()
673            .map(|(k, v)| {
674                // Escape HTML special characters in values
675                let escaped = v
676                    .replace('&', "&amp;")
677                    .replace('"', "&quot;")
678                    .replace('<', "&lt;")
679                    .replace('>', "&gt;");
680                format!("{}=\"{}\"", k, escaped)
681            })
682            .collect::<Vec<_>>()
683            .join(" ")
684    }
685}
686
687/// Generate ARIA attributes from a widget.
688pub fn aria_from_widget(widget: &dyn Widget) -> AriaAttributes {
689    use presentar_core::widget::AccessibleRole;
690
691    let mut attrs = AriaAttributes::new();
692
693    // Set role from widget
694    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    // Set label from widget
723    if let Some(name) = widget.accessible_name() {
724        attrs.label = Some(name.to_string());
725    }
726
727    // Set disabled if not interactive but has a focusable role
728    if !widget.is_interactive() && widget.accessible_role() != AccessibleRole::Generic {
729        attrs.disabled = true;
730    }
731
732    attrs
733}
734
735// =============================================================================
736// Form Accessibility Validation (WCAG 1.3.1, 1.3.5, 3.3.1, 3.3.2, 4.1.2)
737// =============================================================================
738
739/// Form accessibility checker for validating form-specific WCAG requirements.
740pub struct FormA11yChecker;
741
742impl FormA11yChecker {
743    /// Check a form for accessibility violations.
744    ///
745    /// Validates:
746    /// - Label associations (WCAG 1.3.1, 2.4.6)
747    /// - Required field indicators (WCAG 3.3.2)
748    /// - Error messaging (WCAG 3.3.1)
749    /// - Input purpose/autocomplete (WCAG 1.3.5)
750    /// - Form grouping (WCAG 1.3.1)
751    #[must_use]
752    pub fn check(form: &FormAccessibility) -> FormA11yReport {
753        let mut violations = Vec::new();
754
755        // Check all fields
756        for field in &form.fields {
757            Self::check_field(field, &mut violations);
758        }
759
760        // Check form-level requirements
761        Self::check_form_level(form, &mut violations);
762
763        FormA11yReport { violations }
764    }
765
766    /// Check a single form field.
767    fn check_field(field: &FormFieldA11y, violations: &mut Vec<FormViolation>) {
768        // WCAG 1.3.1, 2.4.6: Label association
769        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        // WCAG 3.3.2: Required field indicators
780        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        // WCAG 3.3.1: Error identification
808        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        // WCAG 1.3.5: Input purpose (autocomplete)
845        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        // Check for placeholder-only labeling (anti-pattern)
861        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    /// Check form-level accessibility requirements.
876    fn check_form_level(form: &FormAccessibility, violations: &mut Vec<FormViolation>) {
877        // Check for related fields that should be grouped (WCAG 1.3.1)
878        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            // Radio buttons should be in a fieldset/group
886            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        // Check field groups have legends
905        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        // Check form has accessible name
918        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/// Form accessibility data for validation.
932#[derive(Debug, Clone, Default)]
933pub struct FormAccessibility {
934    /// Form's accessible name
935    pub accessible_name: Option<String>,
936    /// Referenced labelledby ID
937    pub aria_labelledby: Option<String>,
938    /// Form fields
939    pub fields: Vec<FormFieldA11y>,
940    /// Field groups (fieldsets)
941    pub field_groups: Vec<FormFieldGroup>,
942}
943
944impl FormAccessibility {
945    /// Create a new form accessibility descriptor.
946    #[must_use]
947    pub fn new() -> Self {
948        Self::default()
949    }
950
951    /// Add a field.
952    #[must_use]
953    pub fn field(mut self, field: FormFieldA11y) -> Self {
954        self.fields.push(field);
955        self
956    }
957
958    /// Add a field group.
959    #[must_use]
960    pub fn group(mut self, group: FormFieldGroup) -> Self {
961        self.field_groups.push(group);
962        self
963    }
964
965    /// Set accessible name.
966    #[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/// Form field accessibility descriptor.
974#[derive(Debug, Clone, Default)]
975pub struct FormFieldA11y {
976    /// Field ID
977    pub id: String,
978    /// Associated label text
979    pub label: Option<String>,
980    /// Input type
981    pub input_type: Option<InputType>,
982    /// Is required
983    pub required: bool,
984    /// Has visual required indicator
985    pub has_visual_required_indicator: bool,
986    /// aria-required attribute
987    pub aria_required: bool,
988    /// aria-label attribute
989    pub aria_label: Option<String>,
990    /// aria-labelledby attribute
991    pub aria_labelledby: Option<String>,
992    /// aria-describedby attribute
993    pub aria_describedby: Option<String>,
994    /// Has error state
995    pub has_error: bool,
996    /// aria-invalid attribute
997    pub aria_invalid: bool,
998    /// aria-errormessage attribute
999    pub aria_errormessage: Option<String>,
1000    /// Error message text
1001    pub error_message: Option<String>,
1002    /// Autocomplete attribute
1003    pub autocomplete: Option<AutocompleteValue>,
1004    /// Placeholder text
1005    pub placeholder: Option<String>,
1006}
1007
1008impl FormFieldA11y {
1009    /// Create a new field.
1010    #[must_use]
1011    pub fn new(id: impl Into<String>) -> Self {
1012        Self {
1013            id: id.into(),
1014            ..Default::default()
1015        }
1016    }
1017
1018    /// Set label.
1019    #[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    /// Set input type.
1026    #[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    /// Mark as required.
1033    #[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    /// Set required with specific options.
1042    #[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    /// Set error state.
1051    #[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    /// Set autocomplete.
1063    #[must_use]
1064    pub fn with_autocomplete(mut self, value: AutocompleteValue) -> Self {
1065        self.autocomplete = Some(value);
1066        self
1067    }
1068
1069    /// Set placeholder.
1070    #[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    /// Set aria-label.
1077    #[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/// Form field group (fieldset).
1085#[derive(Debug, Clone, Default)]
1086pub struct FormFieldGroup {
1087    /// Group ID
1088    pub id: String,
1089    /// Legend text
1090    pub legend: Option<String>,
1091    /// aria-label (alternative to legend)
1092    pub aria_label: Option<String>,
1093    /// Field IDs in this group
1094    pub field_ids: Vec<String>,
1095}
1096
1097impl FormFieldGroup {
1098    /// Create a new field group.
1099    #[must_use]
1100    pub fn new(id: impl Into<String>) -> Self {
1101        Self {
1102            id: id.into(),
1103            ..Default::default()
1104        }
1105    }
1106
1107    /// Set legend.
1108    #[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    /// Add field ID to group.
1115    #[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/// Input type for form fields.
1123#[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    /// Check if this input type should have autocomplete for WCAG 1.3.5.
1143    #[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/// Autocomplete attribute values (WCAG 1.3.5).
1153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1154pub enum AutocompleteValue {
1155    /// User's name
1156    Name,
1157    /// Given (first) name
1158    GivenName,
1159    /// Family (last) name
1160    FamilyName,
1161    /// Email address
1162    Email,
1163    /// Telephone number
1164    Tel,
1165    /// Street address
1166    StreetAddress,
1167    /// Address level 1 (city)
1168    AddressLevel1,
1169    /// Address level 2 (state/province)
1170    AddressLevel2,
1171    /// Postal code
1172    PostalCode,
1173    /// Country name
1174    Country,
1175    /// Organization
1176    Organization,
1177    /// Username
1178    Username,
1179    /// Current password
1180    CurrentPassword,
1181    /// New password
1182    NewPassword,
1183    /// Credit card number
1184    CcNumber,
1185    /// Credit card expiration
1186    CcExp,
1187    /// Credit card CVV
1188    CcCsc,
1189    /// One-time code
1190    OneTimeCode,
1191    /// Turn off autocomplete
1192    Off,
1193}
1194
1195impl AutocompleteValue {
1196    /// Get the HTML attribute value.
1197    #[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/// Form accessibility violation rule.
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1225pub enum FormA11yRule {
1226    /// Field missing label (1.3.1, 2.4.6)
1227    MissingLabel,
1228    /// Required field missing aria-required (3.3.2)
1229    MissingRequiredIndicator,
1230    /// Required field missing visual indicator (3.3.2)
1231    MissingVisualRequired,
1232    /// Error field missing aria-invalid (3.3.1)
1233    MissingErrorState,
1234    /// Error field missing error message (3.3.1)
1235    MissingErrorMessage,
1236    /// Error message not associated (3.3.1)
1237    ErrorNotAssociated,
1238    /// Field should have autocomplete (1.3.5)
1239    MissingAutocomplete,
1240    /// Placeholder used as sole label (3.3.2)
1241    PlaceholderAsLabel,
1242    /// Related fields not grouped (1.3.1)
1243    RelatedFieldsNotGrouped,
1244    /// Field group missing legend (1.3.1)
1245    GroupMissingLegend,
1246    /// Form missing accessible name (4.1.2)
1247    FormMissingName,
1248}
1249
1250impl FormA11yRule {
1251    /// Get the rule name.
1252    #[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/// Form accessibility violation.
1271#[derive(Debug, Clone)]
1272pub struct FormViolation {
1273    /// Field ID where violation occurred
1274    pub field_id: String,
1275    /// Rule that was violated
1276    pub rule: FormA11yRule,
1277    /// Human-readable message
1278    pub message: String,
1279    /// WCAG success criterion
1280    pub wcag: String,
1281    /// Impact level
1282    pub impact: Impact,
1283}
1284
1285/// Form accessibility report.
1286#[derive(Debug)]
1287pub struct FormA11yReport {
1288    /// List of violations
1289    pub violations: Vec<FormViolation>,
1290}
1291
1292impl FormA11yReport {
1293    /// Check if form passes accessibility.
1294    #[must_use]
1295    pub fn is_passing(&self) -> bool {
1296        self.violations.is_empty()
1297    }
1298
1299    /// Check if form passes with no critical/serious issues.
1300    #[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    /// Get violations by rule.
1309    #[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    /// Get violations for a specific field.
1315    #[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    /// Assert form passes accessibility.
1324    ///
1325    /// # Panics
1326    ///
1327    /// Panics with violation details if form fails accessibility checks.
1328    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}