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::snapshot::{SnapshotCtx, SnapshotNode};
21
22const ALWAYS_INTERACTIVE_TAGS: &[&str] = &["button", "select", "textarea"];
24
25const BUTTON_INPUT_TYPES: &[&str] = &["button", "submit", "reset", "image", "checkbox", "radio"];
27
28#[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 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 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
102fn 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 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 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 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 assert!(!is_interactive(&make_node("div", &[("role", "link")])));
190 }
191}