use presentar_core::widget::AccessibleRole;
use presentar_core::{Color, Widget};
pub const MIN_TOUCH_TARGET_SIZE: f32 = 44.0;
pub const MIN_FOCUS_INDICATOR_AREA: f32 = 2.0;
pub struct A11yChecker;
impl A11yChecker {
#[must_use]
pub fn check(widget: &dyn Widget) -> A11yReport {
let mut violations = Vec::new();
let mut context = CheckContext::default();
Self::check_widget(widget, &mut violations, &mut context);
A11yReport { violations }
}
#[must_use]
pub fn check_with_config(widget: &dyn Widget, config: &A11yConfig) -> A11yReport {
let mut violations = Vec::new();
let mut context = CheckContext {
check_touch_targets: config.check_touch_targets,
check_heading_hierarchy: config.check_heading_hierarchy,
check_focus_indicators: config.check_focus_indicators,
..Default::default()
};
Self::check_widget(widget, &mut violations, &mut context);
A11yReport { violations }
}
fn check_widget(
widget: &dyn Widget,
violations: &mut Vec<A11yViolation>,
context: &mut CheckContext,
) {
if widget.is_interactive() && widget.accessible_name().is_none() {
violations.push(A11yViolation {
rule: "aria-label".to_string(),
message: "Interactive element missing accessible name".to_string(),
wcag: "4.1.2".to_string(),
impact: Impact::Critical,
});
}
if widget.is_interactive() && !widget.is_focusable() {
violations.push(A11yViolation {
rule: "keyboard".to_string(),
message: "Interactive element is not keyboard focusable".to_string(),
wcag: "2.1.1".to_string(),
impact: Impact::Critical,
});
}
if context.check_touch_targets && widget.is_interactive() {
let bounds = widget.bounds();
if bounds.width < MIN_TOUCH_TARGET_SIZE || bounds.height < MIN_TOUCH_TARGET_SIZE {
violations.push(A11yViolation {
rule: "touch-target".to_string(),
message: format!(
"Touch target too small: {}x{} (minimum {}x{})",
bounds.width, bounds.height, MIN_TOUCH_TARGET_SIZE, MIN_TOUCH_TARGET_SIZE
),
wcag: "2.5.5".to_string(),
impact: Impact::Moderate,
});
}
}
if context.check_heading_hierarchy && widget.accessible_role() == AccessibleRole::Heading {
if let Some(level) = Self::heading_level(widget) {
let last_level = context.last_heading_level;
if last_level > 0 && level > last_level + 1 {
violations.push(A11yViolation {
rule: "heading-order".to_string(),
message: format!(
"Heading level skipped: h{} followed by h{} (should be h{} or lower)",
last_level,
level,
last_level + 1
),
wcag: "1.3.1".to_string(),
impact: Impact::Moderate,
});
}
context.last_heading_level = level;
}
}
if context.check_focus_indicators && widget.is_focusable() {
if !Self::has_visible_focus_indicator(widget) {
violations.push(A11yViolation {
rule: "focus-visible".to_string(),
message: "Focusable element may lack visible focus indicator".to_string(),
wcag: "2.4.7".to_string(),
impact: Impact::Serious,
});
}
}
if widget.accessible_role() == AccessibleRole::Image && widget.accessible_name().is_none() {
violations.push(A11yViolation {
rule: "image-alt".to_string(),
message: "Image missing alternative text".to_string(),
wcag: "1.1.1".to_string(),
impact: Impact::Critical,
});
}
for child in widget.children() {
Self::check_widget(child.as_ref(), violations, context);
}
}
fn heading_level(widget: &dyn Widget) -> Option<u8> {
if let Some(name) = widget.accessible_name() {
if name.starts_with('h') || name.starts_with('H') {
if let Ok(level) = name[1..2].parse::<u8>() {
if (1..=6).contains(&level) {
return Some(level);
}
}
}
}
Some(2)
}
fn has_visible_focus_indicator(widget: &dyn Widget) -> bool {
widget.is_focusable()
}
#[must_use]
pub fn check_contrast(
foreground: &Color,
background: &Color,
large_text: bool,
) -> ContrastResult {
let ratio = foreground.contrast_ratio(background);
let (aa_threshold, aaa_threshold) = if large_text {
(3.0, 4.5) } else {
(4.5, 7.0) };
ContrastResult {
ratio,
passes_aa: ratio >= aa_threshold,
passes_aaa: ratio >= aaa_threshold,
}
}
}
#[derive(Debug)]
pub struct A11yReport {
pub violations: Vec<A11yViolation>,
}
impl A11yReport {
#[must_use]
pub fn is_passing(&self) -> bool {
self.violations.is_empty()
}
#[must_use]
pub fn critical(&self) -> Vec<&A11yViolation> {
self.violations
.iter()
.filter(|v| v.impact == Impact::Critical)
.collect()
}
pub fn assert_pass(&self) {
if !self.is_passing() {
let messages: Vec<String> = self
.violations
.iter()
.map(|v| {
format!(
" [{:?}] {}: {} (WCAG {})",
v.impact, v.rule, v.message, v.wcag
)
})
.collect();
panic!(
"Accessibility check failed with {} violation(s):\n{}",
self.violations.len(),
messages.join("\n")
);
}
}
}
#[derive(Debug, Clone)]
pub struct A11yViolation {
pub rule: String,
pub message: String,
pub wcag: String,
pub impact: Impact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Impact {
Minor,
Moderate,
Serious,
Critical,
}
#[derive(Debug, Clone)]
pub struct A11yConfig {
pub check_touch_targets: bool,
pub check_heading_hierarchy: bool,
pub check_focus_indicators: bool,
pub min_contrast_normal: f32,
pub min_contrast_large: f32,
}
impl Default for A11yConfig {
fn default() -> Self {
Self {
check_touch_targets: true,
check_heading_hierarchy: true,
check_focus_indicators: false, min_contrast_normal: 4.5,
min_contrast_large: 3.0,
}
}
}
impl A11yConfig {
#[must_use]
pub fn strict() -> Self {
Self {
check_touch_targets: true,
check_heading_hierarchy: true,
check_focus_indicators: true,
min_contrast_normal: 7.0, min_contrast_large: 4.5, }
}
#[must_use]
pub fn mobile() -> Self {
Self {
check_touch_targets: true,
check_heading_hierarchy: true,
check_focus_indicators: false,
min_contrast_normal: 4.5,
min_contrast_large: 3.0,
}
}
}
#[derive(Debug, Default)]
struct CheckContext {
last_heading_level: u8,
check_touch_targets: bool,
check_heading_hierarchy: bool,
check_focus_indicators: bool,
}
#[derive(Debug, Clone)]
pub struct ContrastResult {
pub ratio: f32,
pub passes_aa: bool,
pub passes_aaa: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AriaAttributes {
pub role: Option<String>,
pub label: Option<String>,
pub described_by: Option<String>,
pub hidden: bool,
pub expanded: Option<bool>,
pub selected: Option<bool>,
pub checked: Option<AriaChecked>,
pub pressed: Option<AriaChecked>,
pub disabled: bool,
pub required: bool,
pub invalid: bool,
pub value_now: Option<f64>,
pub value_min: Option<f64>,
pub value_max: Option<f64>,
pub value_text: Option<String>,
pub level: Option<u8>,
pub pos_in_set: Option<u32>,
pub set_size: Option<u32>,
pub controls: Option<String>,
pub has_popup: Option<String>,
pub busy: bool,
pub live: Option<AriaLive>,
pub atomic: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AriaChecked {
True,
False,
Mixed,
}
impl AriaChecked {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
AriaChecked::True => "true",
AriaChecked::False => "false",
AriaChecked::Mixed => "mixed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AriaLive {
Polite,
Assertive,
Off,
}
impl AriaLive {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
AriaLive::Polite => "polite",
AriaLive::Assertive => "assertive",
AriaLive::Off => "off",
}
}
}
impl AriaAttributes {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.role = Some(role.into());
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub const fn with_hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
#[must_use]
pub const fn with_expanded(mut self, expanded: bool) -> Self {
self.expanded = Some(expanded);
self
}
#[must_use]
pub const fn with_selected(mut self, selected: bool) -> Self {
self.selected = Some(selected);
self
}
#[must_use]
pub const fn with_checked(mut self, checked: AriaChecked) -> Self {
self.checked = Some(checked);
self
}
#[must_use]
pub const fn with_pressed(mut self, pressed: AriaChecked) -> Self {
self.pressed = Some(pressed);
self
}
#[must_use]
pub const fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn with_busy(mut self, busy: bool) -> Self {
self.busy = busy;
self
}
#[must_use]
pub const fn with_atomic(mut self, atomic: bool) -> Self {
self.atomic = atomic;
self
}
#[must_use]
pub fn with_range(mut self, min: f64, max: f64, now: f64) -> Self {
self.value_min = Some(min);
self.value_max = Some(max);
self.value_now = Some(now);
self
}
#[must_use]
pub const fn with_value_now(mut self, value: f64) -> Self {
self.value_now = Some(value);
self
}
#[must_use]
pub const fn with_value_min(mut self, value: f64) -> Self {
self.value_min = Some(value);
self
}
#[must_use]
pub const fn with_value_max(mut self, value: f64) -> Self {
self.value_max = Some(value);
self
}
#[must_use]
pub fn with_controls(mut self, controls: impl Into<String>) -> Self {
self.controls = Some(controls.into());
self
}
#[must_use]
pub fn with_described_by(mut self, described_by: impl Into<String>) -> Self {
self.described_by = Some(described_by.into());
self
}
#[must_use]
pub fn with_has_popup(mut self, has_popup: impl Into<String>) -> Self {
self.has_popup = Some(has_popup.into());
self
}
#[must_use]
pub const fn with_level(mut self, level: u8) -> Self {
self.level = Some(level);
self
}
#[must_use]
pub const fn with_pos_in_set(mut self, pos: u32, size: u32) -> Self {
self.pos_in_set = Some(pos);
self.set_size = Some(size);
self
}
#[must_use]
pub const fn with_live(mut self, live: AriaLive) -> Self {
self.live = Some(live);
self
}
#[must_use]
pub fn to_html_attrs(&self) -> Vec<(String, String)> {
let mut attrs = Vec::new();
if let Some(ref role) = self.role {
attrs.push(("role".to_string(), role.clone()));
}
if let Some(ref label) = self.label {
attrs.push(("aria-label".to_string(), label.clone()));
}
if let Some(ref desc) = self.described_by {
attrs.push(("aria-describedby".to_string(), desc.clone()));
}
if self.hidden {
attrs.push(("aria-hidden".to_string(), "true".to_string()));
}
if let Some(expanded) = self.expanded {
attrs.push(("aria-expanded".to_string(), expanded.to_string()));
}
if let Some(selected) = self.selected {
attrs.push(("aria-selected".to_string(), selected.to_string()));
}
if let Some(checked) = self.checked {
attrs.push(("aria-checked".to_string(), checked.as_str().to_string()));
}
if let Some(pressed) = self.pressed {
attrs.push(("aria-pressed".to_string(), pressed.as_str().to_string()));
}
if let Some(ref popup) = self.has_popup {
attrs.push(("aria-haspopup".to_string(), popup.clone()));
}
if self.disabled {
attrs.push(("aria-disabled".to_string(), "true".to_string()));
}
if self.required {
attrs.push(("aria-required".to_string(), "true".to_string()));
}
if self.invalid {
attrs.push(("aria-invalid".to_string(), "true".to_string()));
}
if let Some(val) = self.value_now {
attrs.push(("aria-valuenow".to_string(), val.to_string()));
}
if let Some(val) = self.value_min {
attrs.push(("aria-valuemin".to_string(), val.to_string()));
}
if let Some(val) = self.value_max {
attrs.push(("aria-valuemax".to_string(), val.to_string()));
}
if let Some(ref text) = self.value_text {
attrs.push(("aria-valuetext".to_string(), text.clone()));
}
if let Some(level) = self.level {
attrs.push(("aria-level".to_string(), level.to_string()));
}
if let Some(pos) = self.pos_in_set {
attrs.push(("aria-posinset".to_string(), pos.to_string()));
}
if let Some(size) = self.set_size {
attrs.push(("aria-setsize".to_string(), size.to_string()));
}
if let Some(ref controls) = self.controls {
attrs.push(("aria-controls".to_string(), controls.clone()));
}
if self.busy {
attrs.push(("aria-busy".to_string(), "true".to_string()));
}
if let Some(live) = self.live {
attrs.push(("aria-live".to_string(), live.as_str().to_string()));
}
if self.atomic {
attrs.push(("aria-atomic".to_string(), "true".to_string()));
}
attrs
}
#[must_use]
pub fn to_html_string(&self) -> String {
self.to_html_attrs()
.into_iter()
.map(|(k, v)| {
let escaped = v
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">");
format!("{}=\"{}\"", k, escaped)
})
.collect::<Vec<_>>()
.join(" ")
}
}
pub fn aria_from_widget(widget: &dyn Widget) -> AriaAttributes {
use presentar_core::widget::AccessibleRole;
let mut attrs = AriaAttributes::new();
let role = match widget.accessible_role() {
AccessibleRole::Generic => None,
AccessibleRole::Button => Some("button"),
AccessibleRole::Checkbox => Some("checkbox"),
AccessibleRole::TextInput => Some("textbox"),
AccessibleRole::Link => Some("link"),
AccessibleRole::Heading => Some("heading"),
AccessibleRole::Image => Some("img"),
AccessibleRole::List => Some("list"),
AccessibleRole::ListItem => Some("listitem"),
AccessibleRole::Table => Some("table"),
AccessibleRole::TableRow => Some("row"),
AccessibleRole::TableCell => Some("cell"),
AccessibleRole::Menu => Some("menu"),
AccessibleRole::MenuItem => Some("menuitem"),
AccessibleRole::ComboBox => Some("combobox"),
AccessibleRole::Slider => Some("slider"),
AccessibleRole::ProgressBar => Some("progressbar"),
AccessibleRole::Tab => Some("tab"),
AccessibleRole::TabPanel => Some("tabpanel"),
AccessibleRole::RadioGroup => Some("radiogroup"),
AccessibleRole::Radio => Some("radio"),
};
if let Some(role) = role {
attrs.role = Some(role.to_string());
}
if let Some(name) = widget.accessible_name() {
attrs.label = Some(name.to_string());
}
if !widget.is_interactive() && widget.accessible_role() != AccessibleRole::Generic {
attrs.disabled = true;
}
attrs
}
pub struct FormA11yChecker;
impl FormA11yChecker {
#[must_use]
pub fn check(form: &FormAccessibility) -> FormA11yReport {
let mut violations = Vec::new();
for field in &form.fields {
Self::check_field(field, &mut violations);
}
Self::check_form_level(form, &mut violations);
FormA11yReport { violations }
}
fn check_field(field: &FormFieldA11y, violations: &mut Vec<FormViolation>) {
if field.label.is_none() && field.aria_label.is_none() && field.aria_labelledby.is_none() {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingLabel,
message: format!("Field '{}' has no associated label", field.id),
wcag: "1.3.1, 2.4.6".to_string(),
impact: Impact::Critical,
});
}
if field.required {
if !field.aria_required {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingRequiredIndicator,
message: format!(
"Required field '{}' does not have aria-required=\"true\"",
field.id
),
wcag: "3.3.2".to_string(),
impact: Impact::Serious,
});
}
if !field.has_visual_required_indicator {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingVisualRequired,
message: format!(
"Required field '{}' lacks visual required indicator (asterisk or text)",
field.id
),
wcag: "3.3.2".to_string(),
impact: Impact::Moderate,
});
}
}
if field.has_error {
if !field.aria_invalid {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingErrorState,
message: format!(
"Field '{}' in error state does not have aria-invalid=\"true\"",
field.id
),
wcag: "3.3.1".to_string(),
impact: Impact::Serious,
});
}
if field.error_message.is_none() {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingErrorMessage,
message: format!("Field '{}' in error state has no error message", field.id),
wcag: "3.3.1".to_string(),
impact: Impact::Serious,
});
}
if field.aria_describedby.is_none() && field.aria_errormessage.is_none() {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::ErrorNotAssociated,
message: format!(
"Error message for '{}' not associated via aria-describedby or aria-errormessage",
field.id
),
wcag: "3.3.1".to_string(),
impact: Impact::Serious,
});
}
}
if let Some(ref input_type) = field.input_type {
if input_type.should_have_autocomplete() && field.autocomplete.is_none() {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::MissingAutocomplete,
message: format!(
"Field '{}' of type {:?} should have autocomplete attribute for autofill",
field.id, input_type
),
wcag: "1.3.5".to_string(),
impact: Impact::Moderate,
});
}
}
if field.placeholder.is_some() && field.label.is_none() && field.aria_label.is_none() {
violations.push(FormViolation {
field_id: field.id.clone(),
rule: FormA11yRule::PlaceholderAsLabel,
message: format!(
"Field '{}' uses placeholder as sole label; placeholders disappear on input",
field.id
),
wcag: "3.3.2".to_string(),
impact: Impact::Serious,
});
}
}
fn check_form_level(form: &FormAccessibility, violations: &mut Vec<FormViolation>) {
let radio_fields: Vec<_> = form
.fields
.iter()
.filter(|f| f.input_type == Some(InputType::Radio))
.collect();
if radio_fields.len() > 1 {
let has_group = form.field_groups.iter().any(|g| {
g.field_ids
.iter()
.any(|id| radio_fields.iter().any(|f| &f.id == id))
});
if !has_group {
violations.push(FormViolation {
field_id: "form".to_string(),
rule: FormA11yRule::RelatedFieldsNotGrouped,
message: "Related radio buttons should be grouped in a fieldset with legend"
.to_string(),
wcag: "1.3.1".to_string(),
impact: Impact::Moderate,
});
}
}
for group in &form.field_groups {
if group.legend.is_none() && group.aria_label.is_none() {
violations.push(FormViolation {
field_id: group.id.clone(),
rule: FormA11yRule::GroupMissingLegend,
message: format!("Field group '{}' has no legend or aria-label", group.id),
wcag: "1.3.1".to_string(),
impact: Impact::Serious,
});
}
}
if form.accessible_name.is_none() && form.aria_labelledby.is_none() {
violations.push(FormViolation {
field_id: "form".to_string(),
rule: FormA11yRule::FormMissingName,
message: "Form should have an accessible name (aria-label or aria-labelledby)"
.to_string(),
wcag: "4.1.2".to_string(),
impact: Impact::Moderate,
});
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FormAccessibility {
pub accessible_name: Option<String>,
pub aria_labelledby: Option<String>,
pub fields: Vec<FormFieldA11y>,
pub field_groups: Vec<FormFieldGroup>,
}
impl FormAccessibility {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn field(mut self, field: FormFieldA11y) -> Self {
self.fields.push(field);
self
}
#[must_use]
pub fn group(mut self, group: FormFieldGroup) -> Self {
self.field_groups.push(group);
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.accessible_name = Some(name.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct FormFieldA11y {
pub id: String,
pub label: Option<String>,
pub input_type: Option<InputType>,
pub required: bool,
pub has_visual_required_indicator: bool,
pub aria_required: bool,
pub aria_label: Option<String>,
pub aria_labelledby: Option<String>,
pub aria_describedby: Option<String>,
pub has_error: bool,
pub aria_invalid: bool,
pub aria_errormessage: Option<String>,
pub error_message: Option<String>,
pub autocomplete: Option<AutocompleteValue>,
pub placeholder: Option<String>,
}
impl FormFieldA11y {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
..Default::default()
}
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_type(mut self, input_type: InputType) -> Self {
self.input_type = Some(input_type);
self
}
#[must_use]
pub fn required(mut self) -> Self {
self.required = true;
self.aria_required = true;
self.has_visual_required_indicator = true;
self
}
#[must_use]
pub fn with_required(mut self, visual: bool, aria: bool) -> Self {
self.required = true;
self.has_visual_required_indicator = visual;
self.aria_required = aria;
self
}
#[must_use]
pub fn with_error(mut self, message: impl Into<String>, associated: bool) -> Self {
self.has_error = true;
self.aria_invalid = true;
self.error_message = Some(message.into());
if associated {
self.aria_describedby = Some(format!("{}-error", self.id));
}
self
}
#[must_use]
pub fn with_autocomplete(mut self, value: AutocompleteValue) -> Self {
self.autocomplete = Some(value);
self
}
#[must_use]
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
#[must_use]
pub fn with_aria_label(mut self, label: impl Into<String>) -> Self {
self.aria_label = Some(label.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct FormFieldGroup {
pub id: String,
pub legend: Option<String>,
pub aria_label: Option<String>,
pub field_ids: Vec<String>,
}
impl FormFieldGroup {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
..Default::default()
}
}
#[must_use]
pub fn with_legend(mut self, legend: impl Into<String>) -> Self {
self.legend = Some(legend.into());
self
}
#[must_use]
pub fn with_field(mut self, field_id: impl Into<String>) -> Self {
self.field_ids.push(field_id.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputType {
Text,
Email,
Password,
Tel,
Url,
Number,
Date,
Time,
Search,
Radio,
Checkbox,
Select,
Textarea,
Hidden,
}
impl InputType {
#[must_use]
pub const fn should_have_autocomplete(&self) -> bool {
matches!(
self,
Self::Text | Self::Email | Self::Password | Self::Tel | Self::Url | Self::Number
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteValue {
Name,
GivenName,
FamilyName,
Email,
Tel,
StreetAddress,
AddressLevel1,
AddressLevel2,
PostalCode,
Country,
Organization,
Username,
CurrentPassword,
NewPassword,
CcNumber,
CcExp,
CcCsc,
OneTimeCode,
Off,
}
impl AutocompleteValue {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Name => "name",
Self::GivenName => "given-name",
Self::FamilyName => "family-name",
Self::Email => "email",
Self::Tel => "tel",
Self::StreetAddress => "street-address",
Self::AddressLevel1 => "address-level1",
Self::AddressLevel2 => "address-level2",
Self::PostalCode => "postal-code",
Self::Country => "country",
Self::Organization => "organization",
Self::Username => "username",
Self::CurrentPassword => "current-password",
Self::NewPassword => "new-password",
Self::CcNumber => "cc-number",
Self::CcExp => "cc-exp",
Self::CcCsc => "cc-csc",
Self::OneTimeCode => "one-time-code",
Self::Off => "off",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormA11yRule {
MissingLabel,
MissingRequiredIndicator,
MissingVisualRequired,
MissingErrorState,
MissingErrorMessage,
ErrorNotAssociated,
MissingAutocomplete,
PlaceholderAsLabel,
RelatedFieldsNotGrouped,
GroupMissingLegend,
FormMissingName,
}
impl FormA11yRule {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::MissingLabel => "missing-label",
Self::MissingRequiredIndicator => "missing-required-indicator",
Self::MissingVisualRequired => "missing-visual-required",
Self::MissingErrorState => "missing-error-state",
Self::MissingErrorMessage => "missing-error-message",
Self::ErrorNotAssociated => "error-not-associated",
Self::MissingAutocomplete => "missing-autocomplete",
Self::PlaceholderAsLabel => "placeholder-as-label",
Self::RelatedFieldsNotGrouped => "related-fields-not-grouped",
Self::GroupMissingLegend => "group-missing-legend",
Self::FormMissingName => "form-missing-name",
}
}
}
#[derive(Debug, Clone)]
pub struct FormViolation {
pub field_id: String,
pub rule: FormA11yRule,
pub message: String,
pub wcag: String,
pub impact: Impact,
}
#[derive(Debug)]
pub struct FormA11yReport {
pub violations: Vec<FormViolation>,
}
impl FormA11yReport {
#[must_use]
pub fn is_passing(&self) -> bool {
self.violations.is_empty()
}
#[must_use]
pub fn is_acceptable(&self) -> bool {
!self
.violations
.iter()
.any(|v| matches!(v.impact, Impact::Critical | Impact::Serious))
}
#[must_use]
pub fn violations_for_rule(&self, rule: FormA11yRule) -> Vec<&FormViolation> {
self.violations.iter().filter(|v| v.rule == rule).collect()
}
#[must_use]
pub fn violations_for_field(&self, field_id: &str) -> Vec<&FormViolation> {
self.violations
.iter()
.filter(|v| v.field_id == field_id)
.collect()
}
pub fn assert_pass(&self) {
if !self.is_passing() {
let messages: Vec<String> = self
.violations
.iter()
.map(|v| {
format!(
" [{:?}] {} ({}): {} (WCAG {})",
v.impact,
v.rule.name(),
v.field_id,
v.message,
v.wcag
)
})
.collect();
panic!(
"Form accessibility check failed with {} violation(s):\n{}",
self.violations.len(),
messages.join("\n")
);
}
}
}