testing_library_dom/
role_helpers.rs1use std::{collections::HashMap, sync::LazyLock};
2
3use aria_query::{
4 AriaRoleDefinitionKey, AriaRoleRelationConcept, AriaRoleRelationConceptAttributeConstraint,
5 ELEMENT_ROLES,
6};
7use dom_accessibility_api::{
8 compute_accessible_description, compute_accessible_name, ComputeTextAlternativeOptions,
9};
10use wasm_bindgen::JsCast;
11use web_sys::{Element, HtmlElement, HtmlInputElement, HtmlOptionElement};
12
13use crate::{pretty_dom, types::ByRoleOptionsCurrent, util::html_collection_to_vec};
14
15struct ElementRole {
16 r#match: Box<dyn Fn(&Element) -> bool + Send + Sync>,
17 roles: Vec<AriaRoleDefinitionKey>,
18 specificity: usize,
19}
20
21fn make_element_selector(element: AriaRoleRelationConcept) -> String {
22 format!(
23 "{}{}",
24 element.name,
25 element
26 .attributes
27 .unwrap_or_default()
28 .into_iter()
29 .map(|attribute| {
30 let constraints = attribute.constraints.unwrap_or_default();
31 let should_not_exist =
32 constraints.contains(&AriaRoleRelationConceptAttributeConstraint::Undefined);
33 let should_be_non_empty =
34 constraints.contains(&AriaRoleRelationConceptAttributeConstraint::Set);
35
36 if let Some(value) = attribute.value {
37 format!("[{}=\"{}\"]", attribute.name, value)
38 } else if should_not_exist {
39 format!(":not([{}])", attribute.name)
40 } else if should_be_non_empty {
41 format!("[{}]:not([{}=\"\"])", attribute.name, attribute.name)
42 } else {
43 format!("[{}]", attribute.name)
44 }
45 })
46 .collect::<Vec<_>>()
47 .join("")
48 )
49}
50
51fn get_selector_specificity(element: &AriaRoleRelationConcept) -> usize {
52 element
53 .attributes
54 .as_ref()
55 .map(|attributes| attributes.len())
56 .unwrap_or(0)
57}
58
59static ELEMENT_ROLE_LIST: LazyLock<Vec<ElementRole>> = LazyLock::new(|| {
60 let mut result = vec![];
61
62 for (element, roles) in ELEMENT_ROLES.iter() {
63 let mut attributes = element.attributes.clone().unwrap_or_default();
64
65 let type_text_index = attributes.iter().position(|attribute| {
67 attribute.name == "type"
68 && attribute
69 .value
70 .as_ref()
71 .is_some_and(|value| value == "text")
72 });
73
74 if let Some(type_text_index) = type_text_index {
75 attributes.splice(type_text_index..type_text_index + 1, []);
76 }
77
78 let selector = make_element_selector(AriaRoleRelationConcept {
79 name: element.name.clone(),
80 attributes: Some(attributes),
81 constraints: element.constraints.clone(),
82 });
83
84 result.push(ElementRole {
85 r#match: Box::new(move |element| {
86 if type_text_index.is_some() {
87 if let Some(input_element) = element.dyn_ref::<HtmlInputElement>() {
88 if input_element.type_() != "text" {
89 return false;
90 }
91 }
92 }
93
94 element.matches(&selector).unwrap_or(false)
95 }),
96 roles: roles.clone(),
97 specificity: get_selector_specificity(element),
98 });
99 }
100
101 result.sort_by(|left, right| right.specificity.cmp(&left.specificity));
102
103 result
104});
105
106pub fn is_subtree_inaccessible(element: &Element) -> bool {
107 if element
108 .dyn_ref::<HtmlElement>()
109 .is_some_and(|html_element| html_element.hidden())
110 {
111 return true;
112 }
113
114 if element.get_attribute("aria-hidden") == Some("true".into()) {
115 return true;
116 }
117
118 let window = element
119 .owner_document()
120 .expect("Element should have owner document.")
121 .default_view()
122 .expect("Owner document should have default view.");
123
124 if window
125 .get_computed_style(element)
126 .expect("Element should be valid.")
127 .expect("Computed style should exist.")
128 .get_property_value("display")
129 .expect("Computed style should have display.")
130 == "none"
131 {
132 return true;
133 }
134
135 false
136}
137
138pub fn is_inaccessible(element: &Element) -> bool {
145 let window = element
146 .owner_document()
147 .expect("Element should have owner document.")
148 .default_view()
149 .expect("Owner document should have default view.");
150
151 if window
153 .get_computed_style(element)
154 .expect("Element should be valid.")
155 .expect("Computed style should exist.")
156 .get_property_value("visibility")
157 .expect("Computed style should have visibility.")
158 == "hidden"
159 {
160 return true;
161 }
162
163 let mut current_element = Some(element.clone());
164 while let Some(element) = current_element.as_ref() {
165 if is_subtree_inaccessible(element) {
166 return true;
167 }
168
169 current_element = element.parent_element();
170 }
171
172 false
173}
174
175pub fn get_implicit_aria_roles(current_node: &Element) -> Vec<AriaRoleDefinitionKey> {
176 for element_role in ELEMENT_ROLE_LIST.iter() {
177 if (element_role.r#match)(current_node) {
178 return element_role.roles.clone();
179 }
180 }
181
182 vec![]
183}
184
185#[derive(Clone, Default)]
186pub struct GetRolesOptions {
187 pub hidden: Option<bool>,
188}
189
190pub fn get_roles(
191 container: Element,
192 options: GetRolesOptions,
193) -> HashMap<AriaRoleDefinitionKey, Vec<Element>> {
194 fn flatten_dom(element: Element) -> Vec<Element> {
195 let mut elements = vec![element.clone()];
196 elements.extend(
197 html_collection_to_vec::<Element>(element.children())
198 .into_iter()
199 .flat_map(flatten_dom)
200 .collect::<Vec<_>>(),
201 );
202 elements
203 }
204
205 let hidden = options.hidden.unwrap_or(false);
206
207 flatten_dom(container)
208 .into_iter()
209 .filter(|element| hidden || !is_inaccessible(element))
210 .fold(HashMap::new(), |mut acc, element| {
211 let roles = if element.has_attribute("role") {
213 element
214 .get_attribute("role")
215 .expect("Attribute should exist.")
216 .split(' ')
217 .filter_map(|role| role.parse::<AriaRoleDefinitionKey>().ok())
218 .take(1)
219 .collect::<Vec<_>>()
220 } else {
221 get_implicit_aria_roles(&element)
222 };
223
224 for role in roles {
225 acc.entry(role)
226 .and_modify(|entry| entry.push(element.clone()))
227 .or_insert_with(|| vec![element.clone()]);
228 }
229
230 acc
231 })
232}
233
234#[derive(Clone, Default)]
235pub struct PrettyRolesOptions {
236 pub hidden: Option<bool>,
237 pub include_description: Option<bool>,
238}
239
240fn pretty_roles(dom: Element, options: PrettyRolesOptions) -> String {
241 let roles = get_roles(
242 dom,
243 GetRolesOptions {
244 hidden: options.hidden,
245 },
246 );
247
248 roles
249 .into_iter()
250 .filter(|(role, _)| *role != AriaRoleDefinitionKey::Generic)
252 .map(|(role, elements)| {
253 let delimiter_bar = "-".repeat(50);
254 let elements_string = elements
255 .iter()
256 .map(|element| {
257 let name_string = format!(
258 "Name \"{}\":\n",
259 compute_accessible_name(element, ComputeTextAlternativeOptions::default())
260 );
261
262 let dom_string = pretty_dom(
263 Some(
264 element
265 .clone_node_with_deep(false)
266 .expect("Node should be cloned.")
267 .dyn_into::<Element>()
268 .expect("Cloned node should be an Element.")
269 .into(),
270 ),
271 None,
272 );
273
274 if options.include_description.unwrap_or(false) {
275 let description_string = format!(
276 "Description \"{}\":",
277 compute_accessible_description(
278 element,
279 ComputeTextAlternativeOptions::default()
280 )
281 );
282
283 format!("{name_string}{description_string}{dom_string}")
284 } else {
285 format!("{name_string}{dom_string}")
286 }
287 })
288 .collect::<Vec<_>>()
289 .join("\n\n");
290
291 format!("{role}:\n\n{elements_string}\n\n{delimiter_bar}")
292 })
293 .collect::<Vec<_>>()
294 .join("\n")
295}
296
297pub fn log_roles(dom: Element, options: PrettyRolesOptions) {
298 log::info!("{}", pretty_roles(dom, options));
299}
300
301pub fn compute_aria_selected(element: &Element) -> Option<bool> {
302 if element.tag_name() == "OPTION" {
303 Some(element.unchecked_ref::<HtmlOptionElement>().selected())
305 } else {
306 check_boolean_attribute(element, "aria-selected")
308 }
309}
310
311pub fn compute_aria_busy(element: &Element) -> bool {
312 element
314 .get_attribute("aria-busy")
315 .is_some_and(|value| value == "true")
316}
317
318pub fn compute_aria_checked(element: &Element) -> Option<bool> {
319 if let Some(input_element) = element.dyn_ref::<HtmlInputElement>() {
320 if input_element.indeterminate() {
324 None
325 } else {
326 Some(input_element.checked())
327 }
328 } else {
329 check_boolean_attribute(element, "aria-checked")
331 }
332}
333
334pub fn compute_aria_pressed(element: &Element) -> Option<bool> {
335 check_boolean_attribute(element, "aria-pressed")
337}
338
339pub fn compute_aria_current(element: &Element) -> ByRoleOptionsCurrent {
340 check_boolean_attribute(element, "aria-current")
343 .map(ByRoleOptionsCurrent::Bool)
344 .or_else(|| {
345 element
346 .get_attribute("aria-current")
347 .map(ByRoleOptionsCurrent::String)
348 })
349 .unwrap_or(ByRoleOptionsCurrent::Bool(false))
350}
351
352pub fn compute_aria_expanded(element: &Element) -> Option<bool> {
353 check_boolean_attribute(element, "aria-expanded")
355}
356
357fn check_boolean_attribute(element: &Element, attribute: &str) -> Option<bool> {
358 let attribute_value = element.get_attribute(attribute);
359
360 if let Some(attribute_value) = attribute_value {
361 if attribute_value == "true" {
362 Some(true)
363 } else if attribute_value == "false" {
364 Some(false)
365 } else {
366 None
367 }
368 } else {
369 None
370 }
371}
372
373pub fn compute_heading_level(element: &Element) -> Option<usize> {
374 element
376 .get_attribute("aria-level")
377 .and_then(|level| level.parse::<usize>().ok())
378 .or_else(|| {
379 match element.tag_name().as_str() {
381 "H1" => Some(1),
382 "H2" => Some(2),
383 "H3" => Some(3),
384 "H4" => Some(4),
385 "H5" => Some(5),
386 "H6" => Some(6),
387 _ => None,
388 }
389 })
390}
391
392pub fn compute_aria_value_now(element: &Element) -> Option<f64> {
393 element
394 .get_attribute("aria-valuenow")
395 .and_then(|value_now| value_now.parse().ok())
396}
397
398pub fn compute_aria_value_max(element: &Element) -> Option<f64> {
399 element
400 .get_attribute("aria-valuemax")
401 .and_then(|value_max| value_max.parse().ok())
402}
403
404pub fn compute_aria_value_min(element: &Element) -> Option<f64> {
405 element
406 .get_attribute("aria-valuemin")
407 .and_then(|value_min| value_min.parse().ok())
408}
409
410pub fn compute_aria_value_text(element: &Element) -> Option<String> {
411 element.get_attribute("aria-valuetext")
412}