testing_library_dom/queries/
role.rs1use 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 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 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 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 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 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 if level.is_some() && role != AriaRole::Heading {
108 return Err(QueryError::Unsupported(format!(
109 "Role \"{role}\" cannot have \"level\" property."
110 )));
111 }
112
113 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 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 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 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 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 &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 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) = ¤t {
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 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 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 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 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};