testing_library_dom/queries/
role.rs

1use std::collections::HashSet;
2
3use aria_query::{AriaProperty, AriaRole, ROLES, ROLE_ELEMENTS};
4use dom_accessibility_api::{
5    compute_accessible_description, compute_accessible_name, ComputeTextAlternativeOptions,
6};
7use web_sys::HtmlElement;
8
9use crate::{
10    build_queries,
11    config::get_config,
12    error::QueryError,
13    matches::matches,
14    role_helpers::{
15        compute_aria_busy, compute_aria_checked, compute_aria_current, compute_aria_expanded,
16        compute_aria_pressed, compute_aria_selected, compute_aria_value_max,
17        compute_aria_value_min, compute_aria_value_now, compute_aria_value_text,
18        compute_heading_level, get_implicit_aria_roles, is_inaccessible,
19    },
20    types::{ByRoleMatcher, ByRoleOptions, Matcher},
21    util::node_list_to_vec,
22};
23
24pub fn _query_all_by_role<M: Into<ByRoleMatcher>>(
25    container: &HtmlElement,
26    role: M,
27    options: ByRoleOptions,
28) -> Result<Vec<HtmlElement>, QueryError> {
29    let role = role.into();
30    let role_string = role.to_string();
31
32    let hidden = options.hidden.unwrap_or(get_config().default_hidden);
33    let name = options.name;
34    let description = options.description;
35    let query_fallbacks = options.query_fallbacks.unwrap_or(false);
36    let selected = options.selected;
37    let busy = options.busy;
38    let checked = options.checked;
39    let pressed = options.pressed;
40    let current = options.current;
41    let level = options.level;
42    let expanded = options.expanded;
43    let options_value = options.value.unwrap_or_default();
44    let value_now = options_value.now;
45    let value_min = options_value.min;
46    let value_max = options_value.max;
47    let value_text = options_value.text;
48
49    // Guard against unknown roles.
50    if selected.is_some()
51        && !ROLES.get(&role.into()).map_or(false, |role| {
52            role.props.contains_key(&AriaProperty::AriaSelected)
53        })
54    {
55        return Err(QueryError::Unsupported(format!(
56            "`aria-selected` is not supported on role \"{role}\"."
57        )));
58    }
59
60    // Guard against unknown roles.
61    if busy.is_some()
62        && !ROLES.get(&role.into()).map_or(false, |role| {
63            role.props.contains_key(&AriaProperty::AriaBusy)
64        })
65    {
66        return Err(QueryError::Unsupported(format!(
67            "`aria-busy` is not supported on role \"{role}\"."
68        )));
69    }
70
71    // Guard against unknown roles.
72    if checked.is_some()
73        && !ROLES.get(&role.into()).map_or(false, |role| {
74            role.props.contains_key(&AriaProperty::AriaChecked)
75        })
76    {
77        return Err(QueryError::Unsupported(format!(
78            "`aria-checked` is not supported on role \"{role}\"."
79        )));
80    }
81
82    // Guard against unknown roles.
83    if pressed.is_some()
84        && !ROLES.get(&role.into()).map_or(false, |role| {
85            role.props.contains_key(&AriaProperty::AriaPressed)
86        })
87    {
88        return Err(QueryError::Unsupported(format!(
89            "`aria-pressed` is not supported on role \"{role}\"."
90        )));
91    }
92
93    // Guard against unknown roles.
94    // All currently released ARIA versions support `aria-current` on all roles.
95    // Leaving this for symmetry and forward compatibility.
96    if current.is_some()
97        && !ROLES.get(&role.into()).map_or(false, |role| {
98            role.props.contains_key(&AriaProperty::AriaCurrent)
99        })
100    {
101        return Err(QueryError::Unsupported(format!(
102            "`aria-current` is not supported on role \"{role}\"."
103        )));
104    }
105
106    // Guard against using `level` option with any role other than `heading`.
107    if level.is_some() && role != AriaRole::Heading {
108        return Err(QueryError::Unsupported(format!(
109            "Role \"{role}\" cannot have \"level\" property."
110        )));
111    }
112
113    // Guard against unknown roles.
114    if value_now.is_some()
115        && !ROLES.get(&role.into()).map_or(false, |role| {
116            role.props.contains_key(&AriaProperty::AriaValuenow)
117        })
118    {
119        return Err(QueryError::Unsupported(format!(
120            "`aria-valuenow` is not supported on role \"{role}\"."
121        )));
122    }
123
124    // Guard against unknown roles.
125    if value_max.is_some()
126        && !ROLES.get(&role.into()).map_or(false, |role| {
127            role.props.contains_key(&AriaProperty::AriaValuemax)
128        })
129    {
130        return Err(QueryError::Unsupported(format!(
131            "`aria-valuemax` is not supported on role \"{role}\"."
132        )));
133    }
134
135    // Guard against unknown roles.
136    if value_min.is_some()
137        && !ROLES.get(&role.into()).map_or(false, |role| {
138            role.props.contains_key(&AriaProperty::AriaValuemin)
139        })
140    {
141        return Err(QueryError::Unsupported(format!(
142            "`aria-valuemin` is not supported on role \"{role}\"."
143        )));
144    }
145
146    // Guard against unknown roles.
147    if value_text.is_some()
148        && !ROLES.get(&role.into()).map_or(false, |role| {
149            role.props.contains_key(&AriaProperty::AriaValuetext)
150        })
151    {
152        return Err(QueryError::Unsupported(format!(
153            "`aria-valuetext` is not supported on role \"{role}\"."
154        )));
155    }
156
157    // Guard against unknown roles.
158    if expanded.is_some()
159        && !ROLES.get(&role.into()).map_or(false, |role| {
160            role.props.contains_key(&AriaProperty::AriaExpanded)
161        })
162    {
163        return Err(QueryError::Unsupported(format!(
164            "`aria-expanded` is not supported on role \"{role}\"."
165        )));
166    }
167
168    Ok(node_list_to_vec::<HtmlElement>(
169        container
170            .query_selector_all(
171                // Only query elements that can be matched by the following filters.
172                &make_role_selector(role),
173            )
174            .map_err(QueryError::JsError)?,
175    )
176    .into_iter()
177    .filter(|node| {
178        if let Some(role_value) = node.get_attribute("role") {
179            if query_fallbacks {
180                return role_value
181                    .split(' ')
182                    .filter(|role_attribute_token| !role_attribute_token.is_empty())
183                    .any(|role_attribute_token| role_attribute_token == role_string);
184            }
185
186            // Other wise only send the first token to match.
187            return role_value
188                .split(' ')
189                .next()
190                .is_some_and(|first_role_attribute_token| {
191                    first_role_attribute_token == role_string
192                });
193        }
194
195        let implicit_roles = get_implicit_aria_roles(node);
196
197        implicit_roles
198            .into_iter()
199            .any(|implicit_role| implicit_role == role.into())
200    })
201    .filter(|element| {
202        if selected.is_some() {
203            return selected == compute_aria_selected(element);
204        }
205        if let Some(busy) = busy {
206            return busy == compute_aria_busy(element);
207        }
208        if checked.is_some() {
209            return checked == compute_aria_checked(element);
210        }
211        if pressed.is_some() {
212            return pressed == compute_aria_pressed(element);
213        }
214        if let Some(current) = &current {
215            return *current == compute_aria_current(element);
216        }
217        if expanded.is_some() {
218            return expanded == compute_aria_expanded(element);
219        }
220        if level.is_some() {
221            return level == compute_heading_level(element);
222        }
223        if value_now.is_some() || value_max.is_some() || value_min.is_some() || value_text.is_some()
224        {
225            let mut value_matches = true;
226
227            if value_now.is_some() {
228                value_matches = value_matches && value_now == compute_aria_value_now(element);
229            }
230            if value_max.is_some() {
231                value_matches = value_matches && value_max == compute_aria_value_max(element);
232            }
233            if value_min.is_some() {
234                value_matches = value_matches && value_min == compute_aria_value_min(element);
235            }
236            if let Some(value_text) = &value_text {
237                let normalizer = |text| text;
238
239                value_matches = value_matches
240                    && matches(
241                        compute_aria_value_text(element),
242                        Some(element),
243                        value_text,
244                        &normalizer,
245                    );
246            }
247
248            return value_matches;
249        }
250
251        // Don't care if ARIA attributes are unspecified.
252        true
253    })
254    .filter(|element| {
255        if let Some(name) = &name {
256            let normalizer = |text| text;
257
258            matches(
259                Some(compute_accessible_name(
260                    element,
261                    ComputeTextAlternativeOptions::default(),
262                )),
263                Some(element),
264                name,
265                &normalizer,
266            )
267        } else {
268            // Don't care
269            true
270        }
271    })
272    .filter(|element| {
273        if let Some(description) = &description {
274            let normalizer = |text| text;
275
276            matches(
277                Some(compute_accessible_description(
278                    element,
279                    ComputeTextAlternativeOptions::default(),
280                )),
281                Some(element),
282                description,
283                &normalizer,
284            )
285        } else {
286            // Don't care
287            true
288        }
289    })
290    .filter(|element| hidden || !is_inaccessible(element))
291    .collect())
292}
293
294fn make_role_selector(role: ByRoleMatcher) -> String {
295    let explicit_role_selector = format!("*[role~=\"{role}\"]");
296
297    let role_relations = ROLE_ELEMENTS.get(&role.into());
298    let implicit_role_selectors = role_relations.map(|role_relations| {
299        role_relations
300            .iter()
301            .map(|relation| relation.name.clone())
302            .collect::<HashSet<String>>()
303    });
304
305    let mut selectors = vec![explicit_role_selector];
306
307    if let Some(implicit_role_selectors) = implicit_role_selectors {
308        selectors.extend(implicit_role_selectors);
309    }
310
311    selectors.join(",")
312}
313
314fn get_name_hint(name: Option<Matcher>) -> String {
315    match name {
316        Some(Matcher::String(name)) => format!(" and name \"{name}\""),
317        Some(Matcher::Regex(name)) => format!(" and name `{}`", name),
318        Some(Matcher::Number(name)) => format!(" and name `{}`", name),
319        Some(Matcher::Function(_name)) => " and name `Fn`".into(),
320        None => "".into(),
321    }
322}
323
324fn get_multiple_error(
325    _container: &HtmlElement,
326    role: ByRoleMatcher,
327    options: ByRoleOptions,
328) -> Result<String, QueryError> {
329    Ok(format!(
330        "Found multiple elements with the role \"{}\"{}",
331        role,
332        get_name_hint(options.name)
333    ))
334}
335
336fn get_missing_error(
337    _container: &HtmlElement,
338    role: ByRoleMatcher,
339    options: ByRoleOptions,
340) -> Result<String, QueryError> {
341    let hidden = options.hidden.unwrap_or(get_config().default_hidden);
342
343    let name_hint = "";
344    let description_hint = "";
345    let role_message = "";
346    // TODO
347
348    Ok(format!(
349        "Unable to find an {}element with the role \"{}\"{}{}\n\n{}",
350        match hidden {
351            true => "",
352            false => "accessible ",
353        },
354        role,
355        name_hint,
356        description_hint,
357        role_message.trim()
358    ))
359}
360
361build_queries!(
362    _query_all_by_role,
363    get_multiple_error,
364    get_missing_error,
365    role,
366    crate::types::ByRoleMatcher,
367    crate::types::ByRoleOptions
368);
369
370pub use internal::{
371    find_all_by_role, find_by_role, get_all_by_role, get_by_role, query_all_by_role, query_by_role,
372};