plumb_core/rules/a11y/
touch_target.rs1use 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#[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 return;
46 }
47
48 for node in ctx.nodes() {
49 if !is_interactive(node) {
50 continue;
51 }
52 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 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}