Skip to main content

plumb_core/rules/a11y/
touch_target.rs

1//! `a11y/touch-target` — flag interactive elements smaller than the
2//! configured minimum target size.
3//!
4//! Implements the WCAG 2.5.8 *Target Size (Minimum)* criterion: any
5//! interactive element with a rendered bounding rect smaller than
6//! `a11y.touch_target.min_width_px` × `a11y.touch_target.min_height_px`
7//! fires a violation. Defaults to 24×24 CSS pixels.
8//!
9//! Interactive nodes are detected by tag name (`button`, `select`,
10//! `textarea`), by `<a href="…">` (anchors with an `href` attribute,
11//! per the HTML spec — bare `<a>` is non-interactive), by
12//! button-shaped `<input>` types, and by ARIA role
13//! (`role="button"`).
14
15use indexmap::IndexMap;
16
17use crate::config::Config;
18use crate::report::{Confidence, Fix, FixKind, Severity, Violation, ViolationSink};
19use crate::rules::Rule;
20use crate::snapshot::{SnapshotCtx, SnapshotNode};
21
22/// Tags that are always interactive without further inspection.
23const ALWAYS_INTERACTIVE_TAGS: &[&str] = &["button", "select", "textarea"];
24
25/// `<input type="…">` values that produce a button-shaped control.
26const BUTTON_INPUT_TYPES: &[&str] = &["button", "submit", "reset", "image", "checkbox", "radio"];
27
28/// Flags interactive elements smaller than `a11y.touch_target`.
29#[derive(Debug, Clone, Copy)]
30pub struct TouchTarget;
31
32impl Rule for TouchTarget {
33    fn id(&self) -> &'static str {
34        "a11y/touch-target"
35    }
36
37    fn default_severity(&self) -> Severity {
38        Severity::Warning
39    }
40
41    fn summary(&self) -> &'static str {
42        "Flags interactive elements smaller than the configured minimum target size."
43    }
44
45    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
46        let min_w = config.a11y.touch_target.min_width_px;
47        let min_h = config.a11y.touch_target.min_height_px;
48        if min_w == 0 && min_h == 0 {
49            // Both thresholds disabled — nothing to enforce.
50            return;
51        }
52
53        for node in ctx.nodes() {
54            if !is_interactive(node) {
55                continue;
56            }
57            let Some(rect) = ctx.rect_for(node.dom_order) else {
58                // Off-screen, hidden, or otherwise un-laid-out — skip.
59                continue;
60            };
61            if rect.width >= min_w && rect.height >= min_h {
62                continue;
63            }
64            let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
65            metadata.insert("rendered_width_px".to_owned(), rect.width.into());
66            metadata.insert("rendered_height_px".to_owned(), rect.height.into());
67            metadata.insert("min_width_px".to_owned(), min_w.into());
68            metadata.insert("min_height_px".to_owned(), min_h.into());
69
70            sink.push(Violation {
71                rule_id: self.id().to_owned(),
72                severity: self.default_severity(),
73                message: format!(
74                    "`{selector}` is {w}×{h}px; WCAG 2.5.8 wants at least {min_w}×{min_h}px for interactive targets.",
75                    selector = node.selector,
76                    w = rect.width,
77                    h = rect.height,
78                ),
79                selector: node.selector.clone(),
80                viewport: ctx.snapshot().viewport.clone(),
81                rect: Some(rect),
82                dom_order: node.dom_order,
83                fix: Some(Fix {
84                    kind: FixKind::Description {
85                        text: format!(
86                            "Enlarge the hit area to at least {min_w}×{min_h}px (CSS pixels). Padding or `min-width` / `min-height` typically does the trick without changing the visual size.",
87                        ),
88                    },
89                    description: format!(
90                        "Bring `{selector}` up to the minimum touch-target size ({min_w}×{min_h}px).",
91                        selector = node.selector,
92                    ),
93                    confidence: Confidence::Low,
94                }),
95                doc_url: "https://plumb.aramhammoudeh.com/rules/a11y-touch-target".to_owned(),
96                metadata,
97            });
98        }
99    }
100}
101
102/// Whether a node represents an interactive control for the purpose of
103/// the touch-target check.
104fn is_interactive(node: &SnapshotNode) -> bool {
105    let tag = node.tag.as_str();
106
107    if ALWAYS_INTERACTIVE_TAGS.contains(&tag) {
108        return true;
109    }
110
111    if tag == "a" && node.attrs.contains_key("href") {
112        return true;
113    }
114
115    if tag == "input" {
116        // Default `<input>` (no `type`) is `text`, which is not a
117        // button-shaped target.
118        let kind = node.attrs.get("type").map_or("text", String::as_str);
119        if BUTTON_INPUT_TYPES.contains(&kind) {
120            return true;
121        }
122    }
123
124    if let Some(role) = node.attrs.get("role") {
125        // Role-based interactivity: `role="button"` is the canonical
126        // case. Other roles (link, switch, etc.) are not enforced
127        // here to keep the rule's contract narrow.
128        if role == "button" {
129            return true;
130        }
131    }
132
133    false
134}
135
136#[cfg(test)]
137mod tests {
138    use super::is_interactive;
139    use crate::snapshot::SnapshotNode;
140    use indexmap::IndexMap;
141
142    fn make_node(tag: &str, attrs: &[(&str, &str)]) -> SnapshotNode {
143        let mut attr_map = IndexMap::new();
144        for (k, v) in attrs {
145            attr_map.insert((*k).to_owned(), (*v).to_owned());
146        }
147        SnapshotNode {
148            dom_order: 0,
149            selector: tag.to_owned(),
150            tag: tag.to_owned(),
151            attrs: attr_map,
152            computed_styles: IndexMap::new(),
153            rect: None,
154            parent: None,
155            children: Vec::new(),
156        }
157    }
158
159    #[test]
160    fn always_interactive_tags_match() {
161        for tag in ["button", "select", "textarea"] {
162            assert!(is_interactive(&make_node(tag, &[])), "{tag}");
163        }
164    }
165
166    #[test]
167    fn anchor_requires_href() {
168        assert!(!is_interactive(&make_node("a", &[])));
169        assert!(is_interactive(&make_node("a", &[("href", "/x")])));
170    }
171
172    #[test]
173    fn input_button_types_match() {
174        for kind in ["button", "submit", "reset", "image", "checkbox", "radio"] {
175            assert!(
176                is_interactive(&make_node("input", &[("type", kind)])),
177                "{kind}"
178            );
179        }
180        // Bare <input> defaults to text — not interactive for this rule.
181        assert!(!is_interactive(&make_node("input", &[])));
182        assert!(!is_interactive(&make_node("input", &[("type", "text")])));
183    }
184
185    #[test]
186    fn role_button_matches() {
187        assert!(is_interactive(&make_node("div", &[("role", "button")])));
188        // Other roles are out of scope for the rule.
189        assert!(!is_interactive(&make_node("div", &[("role", "link")])));
190    }
191}