testing_library_dom/
role_helpers.rs

1use 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        // https://github.com/testing-library/dom-testing-library/issues/814
66        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
138// Partial implementation https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
139// which should only be used for elements with a non-presentational role i.e.
140// `role="none"` and `role="presentation"` will not be excluded.
141//
142// Implements aria-hidden semantics (i.e. parent overrides child)
143// Ignores "Child Presentational: True" characteristics.
144pub 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    // Since visibility is inherited we can exit early.
152    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            // TODO: This violates html-aria which does not allow any role on every element.
212            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        // We prefer to skip generic role, we don't recommend it.
251        .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        // Implicit value from HTML-AAM mappings: https://www.w3.org/TR/html-aam-1.0/#att-selected.
304        Some(element.unchecked_ref::<HtmlOptionElement>().selected())
305    } else {
306        // Explicit value.
307        check_boolean_attribute(element, "aria-selected")
308    }
309}
310
311pub fn compute_aria_busy(element: &Element) -> bool {
312    // https://www.w3.org/TR/wai-aria-1.1/#aria-busy
313    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        // Implicit value from HTML-AAM mappings:
321        // https://www.w3.org/TR/html-aam-1.0/#att-indeterminate
322        // https://www.w3.org/TR/html-aam-1.0/#att-checked
323        if input_element.indeterminate() {
324            None
325        } else {
326            Some(input_element.checked())
327        }
328    } else {
329        // Explicit value.
330        check_boolean_attribute(element, "aria-checked")
331    }
332}
333
334pub fn compute_aria_pressed(element: &Element) -> Option<bool> {
335    // https://www.w3.org/TR/wai-aria-1.1/#aria-pressed
336    check_boolean_attribute(element, "aria-pressed")
337}
338
339pub fn compute_aria_current(element: &Element) -> ByRoleOptionsCurrent {
340    // https://www.w3.org/TR/wai-aria-1.1/#aria-current
341
342    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    // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded
354    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    // Explicit value: https://www.w3.org/TR/wai-aria-1.2/#aria-level.
375    element
376        .get_attribute("aria-level")
377        .and_then(|level| level.parse::<usize>().ok())
378        .or_else(|| {
379            // Implicit value: https://w3c.github.io/html-aam/#el-h1-h6.
380            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}