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::rules::util::is_interactive;
21use crate::snapshot::SnapshotCtx;
22
23/// Flags interactive elements smaller than `a11y.touch_target`.
24#[derive(Debug, Clone, Copy)]
25pub struct TouchTarget;
26
27impl Rule for TouchTarget {
28    fn id(&self) -> &'static str {
29        "a11y/touch-target"
30    }
31
32    fn default_severity(&self) -> Severity {
33        Severity::Warning
34    }
35
36    fn summary(&self) -> &'static str {
37        "Flags interactive elements smaller than the configured minimum target size."
38    }
39
40    fn check(&self, ctx: &SnapshotCtx<'_>, config: &Config, sink: &mut ViolationSink<'_>) {
41        let min_w = config.a11y.touch_target.min_width_px;
42        let min_h = config.a11y.touch_target.min_height_px;
43        if min_w == 0 && min_h == 0 {
44            // Both thresholds disabled — nothing to enforce.
45            return;
46        }
47
48        for node in ctx.nodes() {
49            if !is_interactive(node) {
50                continue;
51            }
52            // WCAG 2.5.8 inline exception: targets whose size is
53            // constrained by the line-height of surrounding text are
54            // exempt. Inline prose links (`<a>` with computed
55            // `display: inline`) are the canonical case.
56            if node.tag == "a"
57                && node.computed_styles.get("display").map(String::as_str) == Some("inline")
58            {
59                continue;
60            }
61            let Some(rect) = ctx.rect_for(node.dom_order) else {
62                // Off-screen, hidden, or otherwise un-laid-out — skip.
63                continue;
64            };
65            if rect.width >= min_w && rect.height >= min_h {
66                continue;
67            }
68            let mut metadata: IndexMap<String, serde_json::Value> = IndexMap::new();
69            metadata.insert("rendered_width_px".to_owned(), rect.width.into());
70            metadata.insert("rendered_height_px".to_owned(), rect.height.into());
71            metadata.insert("min_width_px".to_owned(), min_w.into());
72            metadata.insert("min_height_px".to_owned(), min_h.into());
73
74            sink.push(Violation {
75                rule_id: self.id().to_owned(),
76                severity: self.default_severity(),
77                message: format!(
78                    "`{selector}` is {w}×{h}px; WCAG 2.5.8 wants at least {min_w}×{min_h}px for interactive targets.",
79                    selector = node.selector,
80                    w = rect.width,
81                    h = rect.height,
82                ),
83                selector: node.selector.clone(),
84                viewport: ctx.snapshot().viewport.clone(),
85                rect: Some(rect),
86                dom_order: node.dom_order,
87                fix: Some(Fix {
88                    kind: FixKind::Description {
89                        text: format!(
90                            "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.",
91                        ),
92                    },
93                    description: format!(
94                        "Bring `{selector}` up to the minimum touch-target size ({min_w}×{min_h}px).",
95                        selector = node.selector,
96                    ),
97                    confidence: Confidence::Low,
98                }),
99                doc_url: "https://plumb.aramhammoudeh.com/rules/a11y-touch-target".to_owned(),
100                metadata,
101            });
102        }
103    }
104}