1use std::rc::Rc;
2
3use regex::Regex;
4use web_sys::{
5 wasm_bindgen::JsCast, window, CssStyleDeclaration, Element, HtmlFieldSetElement,
6 HtmlInputElement, HtmlLabelElement, HtmlLegendElement, HtmlOptGroupElement, HtmlSelectElement,
7 HtmlSlotElement, HtmlTableCaptionElement, HtmlTableElement, HtmlTextAreaElement, Node,
8 SvgElement, SvgTitleElement,
9};
10
11use crate::util::{
12 array_to_vec, has_any_concrete_roles, html_collection_to_vec, node_list_to_vec, query_id_refs,
13 PRESENTATION_ROLES,
14};
15
16#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
17pub enum Compute {
18 Name,
19 Description,
20}
21
22pub type GetComputedStyle = Rc<dyn Fn(&Element, Option<&str>) -> CssStyleDeclaration>;
23
24#[derive(Clone, Default)]
26pub struct ComputeTextAlternativeOptions {
27 pub compute: Option<Compute>,
28
29 pub get_computed_style: Option<GetComputedStyle>,
31
32 pub hidden: Option<bool>,
37}
38
39fn as_flat_string(s: String) -> String {
40 Regex::new(r"\s\s+")
41 .expect("Regex should be valid.")
42 .replace_all(&s, " ")
43 .to_string()
44}
45
46fn is_hidden(node: &Node, get_computed_style_implementation: GetComputedStyle) -> bool {
47 if let Some(element) = node.dyn_ref::<Element>() {
48 if element.has_attribute("hidden")
49 || element.get_attribute("aria-hidden") == Some("true".into())
50 {
51 true
52 } else {
53 let style = get_computed_style_implementation(element, None);
54
55 style
56 .get_property_value("display")
57 .expect("Computed style should have display.")
58 == "none"
59 || style
60 .get_property_value("visibility")
61 .expect("Computed style should have visibility.")
62 == "hidden"
63 }
64 } else {
65 false
66 }
67}
68
69fn is_control(node: &Node) -> bool {
70 has_any_concrete_roles(node, vec!["button", "combobox", "listbox", "textbox"])
71 || has_abstract_role(node, "range")
72}
73
74fn has_abstract_role(node: &Node, role: &str) -> bool {
75 node.dyn_ref::<Element>().is_some_and(|element| match role {
76 "range" => has_any_concrete_roles(
77 element,
78 vec!["meter", "progressbar", "scrollbar", "slider", "spinbutton"],
79 ),
80 _ => unreachable!("No knowledge about abstract role '{role}'. This is likely a bug :("),
81 })
82}
83
84fn query_selector_all_subtree(element: &Element, selectors: &str) -> Vec<Element> {
85 let mut elements = node_list_to_vec(
86 element
87 .query_selector_all(selectors)
88 .expect("Element should be queried."),
89 );
90
91 for root in query_id_refs(element, "aria-owns") {
92 elements.extend(node_list_to_vec(
93 root.query_selector_all(selectors)
94 .expect("Element should be queried."),
95 ));
96 }
97
98 elements
99}
100
101fn query_selected_options(listbox: &Element) -> Vec<Element> {
102 if let Some(select_element) = listbox.dyn_ref::<HtmlSelectElement>() {
103 html_collection_to_vec(select_element.selected_options())
104 } else {
105 query_selector_all_subtree(listbox, "[aria-selected=\"true\"]")
106 }
107}
108
109fn is_marked_presentational(node: &Node) -> bool {
110 has_any_concrete_roles(node, PRESENTATION_ROLES.into())
111}
112
113fn is_native_host_language_text_alternative_element(node: &Node) -> bool {
114 node.is_instance_of::<HtmlTableCaptionElement>()
119}
120
121fn allows_name_from_content(node: &Node) -> bool {
122 has_any_concrete_roles(
123 node,
124 vec![
125 "button",
126 "cell",
127 "checkbox",
128 "columnheader",
129 "gridcell",
130 "heading",
131 "label",
132 "legend",
133 "link",
134 "menuitem",
135 "menuitemcheckbox",
136 "menuitemradio",
137 "option",
138 "radio",
139 "row",
140 "rowheader",
141 "switch",
142 "tab",
143 "tooltip",
144 "treeitem",
145 ],
146 )
147}
148
149fn is_descendant_of_native_host_language_text_alternative_element(_node: &Node) -> bool {
151 false
152}
153
154fn get_value_of_textbox(element: &Element) -> String {
155 if let Some(input_element) = element.dyn_ref::<HtmlInputElement>() {
156 input_element.value()
157 } else if let Some(text_area_element) = element.dyn_ref::<HtmlTextAreaElement>() {
158 text_area_element.value()
159 } else {
160 element.text_content().unwrap_or("".into())
162 }
163}
164
165fn get_textual_content(declaration: CssStyleDeclaration) -> String {
166 let content = declaration
167 .get_property_value("content")
168 .expect("CssStyleDeclaration should have content.");
169 if Regex::new(r#"^["'].*["']$"#)
170 .expect("Regex should be valid.")
171 .is_match(&content)
172 {
173 (&content[1..content.len() - 1]).into()
174 } else {
175 "".into()
176 }
177}
178
179fn is_labelable_element(element: &Element) -> bool {
182 let local_name = element.local_name();
183
184 local_name == "button"
185 || (local_name == "input" && element.get_attribute("type") != Some("hidden".into()))
186 || local_name == "meter"
187 || local_name == "output"
188 || local_name == "progress"
189 || local_name == "select"
190 || local_name == "textarea"
191}
192
193fn find_labelable_element(element: &Element) -> Option<Element> {
196 if is_labelable_element(element) {
197 return Some(element.clone());
198 }
199
200 for child_node in node_list_to_vec::<Node>(element.child_nodes()) {
201 if let Some(child_element) = child_node.dyn_ref::<Element>() {
202 let descendant_labelable_element = find_labelable_element(child_element);
203 if let Some(descendant_labelable_element) = descendant_labelable_element {
204 return Some(descendant_labelable_element);
205 }
206 }
207 }
208
209 None
210}
211
212fn get_control_of_label(label: &HtmlLabelElement) -> Option<Element> {
215 if let Some(control) = label.control() {
216 return Some(control.into());
217 }
218
219 let html_for = label.get_attribute("for");
220 if let Some(html_for) = html_for {
221 return label
222 .owner_document()
223 .expect("Owner document should exist.")
224 .get_element_by_id(&html_for);
225 }
226
227 find_labelable_element(label)
228}
229
230fn get_labels(element: &Element) -> Vec<HtmlLabelElement> {
233 if let Some(input_element) = element.dyn_ref::<HtmlInputElement>() {
234 input_element
235 .labels()
236 .map(node_list_to_vec)
237 .unwrap_or_default()
238 } else if !is_labelable_element(element) {
239 vec![]
240 } else {
241 let document = element
242 .owner_document()
243 .expect("Owner document should exist.");
244 node_list_to_vec(
245 document
246 .query_selector_all("label")
247 .expect("Document should be queried."),
248 )
249 .into_iter()
250 .filter(|label| get_control_of_label(label).is_some_and(|label| label == *element))
251 .collect()
252 }
253}
254
255fn get_slot_contents(slot: &HtmlSlotElement) -> Vec<Node> {
257 let assigned_nodes = slot.assigned_nodes();
261 if assigned_nodes.length() == 0 {
262 node_list_to_vec(slot.child_nodes())
264 } else {
265 array_to_vec(assigned_nodes)
266 }
267}
268
269struct ComputeTextAlternativeContext {
270 is_embedded_in_label: bool,
271 is_referenced: bool,
272 recursion: bool,
273}
274
275struct ComputeMiscTextAlternativeContext {
276 is_embedded_in_label: bool,
277 #[expect(dead_code)]
278 is_referenced: bool,
279}
280
281pub fn compute_text_alternative(root: &Element, options: ComputeTextAlternativeOptions) -> String {
283 let mut consulted_nodes: Vec<Node> = vec![];
284
285 let compute = options.compute.unwrap_or(Compute::Name);
286 let uncached_get_computed_style = options.get_computed_style.unwrap_or_else(|| {
287 Rc::new(|element, pseudo_elt| {
288 let window = window().expect("Window should exist.");
289
290 if let Some(pseudo_elt) = pseudo_elt {
291 window.get_computed_style_with_pseudo_elt(element, pseudo_elt)
292 } else {
293 window.get_computed_style(element)
294 }
295 .expect("Element should be valid.")
296 .expect("Computed style should exist.")
297 })
298 });
299 let hidden = options.hidden.unwrap_or(false);
300
301 let get_computed_style: GetComputedStyle = Rc::new({
302 let uncached_get_computed_style = uncached_get_computed_style.clone();
303
304 move |element, pseudo_elt| {
305 uncached_get_computed_style(element, pseudo_elt)
307 }
308 });
309
310 fn compute_misc_text_alternative(
312 compute: Compute,
313 hidden: bool,
314 uncached_get_computed_style: GetComputedStyle,
315 get_computed_style: GetComputedStyle,
316 consulted_nodes: &mut Vec<Node>,
317 node: &Node,
318 context: ComputeMiscTextAlternativeContext,
319 ) -> String {
320 let mut accumalated_text = "".to_string();
321
322 if let Some(element) = node.dyn_ref::<Element>() {
323 let pseudo_before = uncached_get_computed_style(element, Some("::before"));
324 let before_content = get_textual_content(pseudo_before);
325 accumalated_text = format!("{before_content} {accumalated_text}");
326 }
327
328 let child_nodes = node
330 .dyn_ref::<HtmlSlotElement>()
331 .map(get_slot_contents)
332 .unwrap_or_else(|| {
333 let mut nodes = node_list_to_vec(node.child_nodes());
334 nodes.extend(
335 query_id_refs(node, "aria-owns")
336 .into_iter()
337 .map(|element| element.into()),
338 );
339 nodes
340 });
341 for child in child_nodes {
342 let result = inner_compute_text_alternative(
343 compute,
344 hidden,
345 uncached_get_computed_style.clone(),
346 get_computed_style.clone(),
347 consulted_nodes,
348 &child,
349 ComputeTextAlternativeContext {
350 is_embedded_in_label: context.is_embedded_in_label,
351 is_referenced: false,
352 recursion: true,
353 },
354 );
355 let display = if let Some(element) = child.dyn_ref::<Element>() {
357 get_computed_style(element, None)
358 .get_property_value("display")
359 .expect("Computed style should have display.")
360 } else {
361 "inline".into()
362 };
363 let separator = if display != "inline" { " " } else { "" };
364 accumalated_text = format!("{accumalated_text}{separator}{result}{separator}");
366 }
367
368 if let Some(element) = node.dyn_ref::<Element>() {
369 let pseudo_after = uncached_get_computed_style(element, Some("::after"));
370 let after_content = get_textual_content(pseudo_after);
371 accumalated_text = format!("{accumalated_text} {after_content}");
372 }
373
374 return accumalated_text.trim().into();
375 }
376
377 fn use_attribute(
378 consulted_nodes: &mut Vec<Node>,
379 element: &Element,
380 attribute_name: &str,
381 ) -> Option<String> {
382 if let Some(attribute) = element.get_attribute_node(attribute_name) {
383 let value = attribute.value();
384 if !consulted_nodes.contains(&attribute) && !value.trim().is_empty() {
385 consulted_nodes.push(attribute.into());
386 return Some(value);
387 }
388 }
389
390 None
391 }
392
393 fn compute_tooltip_attribute_value(
394 consulted_nodes: &mut Vec<Node>,
395 node: &Node,
396 ) -> Option<String> {
397 node.dyn_ref::<Element>()
398 .and_then(|element| use_attribute(consulted_nodes, element, "title"))
399 }
400
401 fn compute_element_text_alternative(
402 compute: Compute,
403 hidden: bool,
404 uncached_get_computed_style: GetComputedStyle,
405 get_computed_style: GetComputedStyle,
406 consulted_nodes: &mut Vec<Node>,
407 node: &Node,
408 ) -> Option<String> {
409 if let Some(element) = node.dyn_ref::<Element>() {
410 if element.is_instance_of::<HtmlFieldSetElement>() {
411 consulted_nodes.push(node.clone());
414
415 let children = element.child_nodes();
416 for i in 0..children.length() {
417 let child = children.item(i).expect("Item should exist.");
418
419 if child.is_instance_of::<HtmlLegendElement>() {
420 return Some(inner_compute_text_alternative(
421 compute,
422 hidden,
423 uncached_get_computed_style,
424 get_computed_style,
425 consulted_nodes,
426 &child,
427 ComputeTextAlternativeContext {
428 is_embedded_in_label: false,
429 is_referenced: false,
430 recursion: false,
431 },
432 ));
433 }
434 }
435 } else if element.is_instance_of::<HtmlTableElement>() {
436 consulted_nodes.push(node.clone());
439
440 let children = element.child_nodes();
441 for i in 0..children.length() {
442 let child = children.item(i).expect("Item should exist.");
443
444 if child.is_instance_of::<HtmlTableCaptionElement>() {
445 return Some(inner_compute_text_alternative(
446 compute,
447 hidden,
448 uncached_get_computed_style,
449 get_computed_style,
450 consulted_nodes,
451 &child,
452 ComputeTextAlternativeContext {
453 is_embedded_in_label: false,
454 is_referenced: false,
455 recursion: false,
456 },
457 ));
458 }
459 }
460 } else if element.is_instance_of::<SvgElement>() {
461 consulted_nodes.push(node.clone());
464
465 let children = element.child_nodes();
466 for i in 0..children.length() {
467 let child = children.item(i).expect("Item should exist.");
468
469 if child.is_instance_of::<SvgTitleElement>() {
470 return child.text_content();
471 }
472 }
473 } else if element.local_name() == "img" || element.local_name() == "area" {
474 if let Some(name_from_alt) = use_attribute(consulted_nodes, element, "alt") {
477 return Some(name_from_alt);
478 }
479 } else if element.is_instance_of::<HtmlOptGroupElement>() {
480 if let Some(name_from_label) = use_attribute(consulted_nodes, element, "label") {
481 return Some(name_from_label);
482 }
483 }
484
485 if let Some(input_element) = element.dyn_ref::<HtmlInputElement>() {
486 if input_element.type_() == "button"
487 || input_element.type_() == "submit"
488 || input_element.type_() == "reset"
489 {
490 if let Some(name_from_value) = use_attribute(consulted_nodes, element, "value")
492 {
493 return Some(name_from_value);
494 }
495
496 if input_element.type_() == "submit" {
498 return Some("Submit".into());
499 }
500 if input_element.type_() == "reset" {
502 return Some("Reset".into());
503 }
504 }
505 }
506
507 let labels = get_labels(element);
508 if !labels.is_empty() {
509 consulted_nodes.push(node.clone());
510
511 return Some(
512 labels
513 .into_iter()
514 .map(|element| {
515 inner_compute_text_alternative(
516 compute,
517 hidden,
518 uncached_get_computed_style.clone(),
519 get_computed_style.clone(),
520 consulted_nodes,
521 &element,
522 ComputeTextAlternativeContext {
523 is_embedded_in_label: true,
524 is_referenced: false,
525 recursion: true,
526 },
527 )
528 })
529 .filter(|label| !label.is_empty())
530 .collect::<Vec<_>>()
531 .join(" "),
532 );
533 }
534
535 if let Some(input_element) = node.dyn_ref::<HtmlInputElement>() {
539 if input_element.type_() == "image" {
540 let name_for_alt = use_attribute(consulted_nodes, input_element, "alt");
541 if let Some(name_for_alt) = name_for_alt {
542 return Some(name_for_alt);
543 }
544
545 let name_for_title = use_attribute(consulted_nodes, input_element, "title");
546 if let Some(name_for_alt) = name_for_title {
547 return Some(name_for_alt);
548 }
549
550 return Some("Submit Query".into());
552 }
553 }
554
555 if has_any_concrete_roles(node, vec!["button"]) {
556 let name_from_sub_tree = compute_misc_text_alternative(
558 compute,
559 hidden,
560 uncached_get_computed_style,
561 get_computed_style,
562 consulted_nodes,
563 node,
564 ComputeMiscTextAlternativeContext {
565 is_embedded_in_label: false,
566 is_referenced: false,
567 },
568 );
569 if !name_from_sub_tree.is_empty() {
570 return Some(name_from_sub_tree);
571 }
572 }
573 }
574
575 None
576 }
577
578 fn inner_compute_text_alternative(
579 compute: Compute,
580 hidden: bool,
581 uncached_get_computed_style: GetComputedStyle,
582 get_computed_style: GetComputedStyle,
583 consulted_nodes: &mut Vec<Node>,
584 current: &Node,
585 context: ComputeTextAlternativeContext,
586 ) -> String {
587 if consulted_nodes.contains(current) {
588 return "".into();
589 }
590
591 if !hidden && is_hidden(current, get_computed_style.clone()) && !context.is_referenced {
593 consulted_nodes.push(current.clone());
594 return "".into();
595 }
596
597 if let Some(current) = current.dyn_ref::<Element>() {
599 if let Some(label_attribute_node) = current.get_attribute_node("aria-labelledby") {
600 let label_elements = if !consulted_nodes.contains(&label_attribute_node) {
602 query_id_refs(current, "aria-labelledby")
603 } else {
604 vec![]
605 };
606
607 if compute == Compute::Name && !context.is_referenced && !label_elements.is_empty()
608 {
609 consulted_nodes.push(label_attribute_node.unchecked_into::<Node>());
610
611 return label_elements
612 .into_iter()
613 .map(move |element| {
614 inner_compute_text_alternative(
616 compute,
617 hidden,
618 uncached_get_computed_style.clone(),
619 get_computed_style.clone(),
620 consulted_nodes,
621 &element,
622 ComputeTextAlternativeContext {
623 is_embedded_in_label: context.is_embedded_in_label,
624 is_referenced: true,
625 recursion: false,
628 },
629 )
630 })
631 .collect::<Vec<_>>()
632 .join(" ");
633 }
634 }
635 }
636
637 let skip_to_step_2e = context.recursion && is_control(current) && compute == Compute::Name;
641 if !skip_to_step_2e {
642 let aria_label = current
643 .dyn_ref::<Element>()
644 .and_then(|current| current.get_attribute("aria-label"))
645 .unwrap_or_default()
646 .trim()
647 .to_string();
648 if !aria_label.is_empty() && compute == Compute::Name {
649 consulted_nodes.push(current.clone());
650 return aria_label;
651 }
652
653 if !is_marked_presentational(current) {
655 if let Some(element_text_alternative) = compute_element_text_alternative(
656 compute,
657 hidden,
658 uncached_get_computed_style.clone(),
659 get_computed_style.clone(),
660 consulted_nodes,
661 current,
662 ) {
663 consulted_nodes.push(current.clone());
664 return element_text_alternative;
665 }
666 }
667 }
668
669 if has_any_concrete_roles(current, vec!["menu"]) {
672 consulted_nodes.push(current.clone());
673 return "".into();
674 }
675
676 if skip_to_step_2e || context.is_embedded_in_label || context.is_referenced {
678 if has_any_concrete_roles(current, vec!["combobox", "listbox"]) {
679 consulted_nodes.push(current.clone());
680
681 let selected_options = query_selected_options(
682 current
683 .dyn_ref::<Element>()
684 .expect("Node should be an Element."),
685 );
686 if selected_options.is_empty() {
687 return current
689 .dyn_ref::<HtmlInputElement>()
690 .map(|input_element| input_element.value())
691 .unwrap_or("".into());
692 }
693 return selected_options
694 .iter()
695 .map(|selected_option| {
696 inner_compute_text_alternative(
697 compute,
698 hidden,
699 uncached_get_computed_style.clone(),
700 get_computed_style.clone(),
701 consulted_nodes,
702 selected_option,
703 ComputeTextAlternativeContext {
704 is_embedded_in_label: context.is_embedded_in_label,
705 is_referenced: false,
706 recursion: true,
707 },
708 )
709 })
710 .collect::<Vec<_>>()
711 .join(" ");
712 }
713 if has_abstract_role(current, "range") {
714 consulted_nodes.push(current.clone());
715 let element = current
716 .dyn_ref::<Element>()
717 .expect("Node should be an Element.");
718 if element.has_attribute("aria-valuetext") {
719 return element
720 .get_attribute("aria-valuetext")
721 .expect("Attribute should exist.");
722 }
723 if element.has_attribute("aria-valuenow") {
724 return element
725 .get_attribute("aria-valuenow")
726 .expect("Attribute should exist.");
727 }
728 return element.get_attribute("value").unwrap_or("".into());
729 }
730 if has_any_concrete_roles(current, vec!["textbox"]) {
731 consulted_nodes.push(current.clone());
732
733 return get_value_of_textbox(
734 current
735 .dyn_ref::<Element>()
736 .expect("Node should be an Element."),
737 );
738 }
739 }
740
741 if allows_name_from_content(current)
743 || (current.is_instance_of::<Element>() && context.is_referenced)
744 || is_native_host_language_text_alternative_element(current)
745 || is_descendant_of_native_host_language_text_alternative_element(current)
746 {
747 let accumulated_text_2f = compute_misc_text_alternative(
748 compute,
749 hidden,
750 uncached_get_computed_style.clone(),
751 get_computed_style.clone(),
752 consulted_nodes,
753 current,
754 ComputeMiscTextAlternativeContext {
755 is_embedded_in_label: context.is_embedded_in_label,
756 is_referenced: false,
757 },
758 );
759 if !accumulated_text_2f.is_empty() {
760 consulted_nodes.push(current.clone());
761 return accumulated_text_2f;
762 }
763 }
764
765 if current.node_type() == Node::TEXT_NODE {
766 consulted_nodes.push(current.clone());
767 return current.text_content().unwrap_or("".into());
768 }
769
770 if context.recursion {
771 consulted_nodes.push(current.clone());
772 return compute_misc_text_alternative(
773 compute,
774 hidden,
775 uncached_get_computed_style,
776 get_computed_style,
777 consulted_nodes,
778 current,
779 ComputeMiscTextAlternativeContext {
780 is_embedded_in_label: context.is_embedded_in_label,
781 is_referenced: false,
782 },
783 );
784 }
785
786 let tooltip_attribute_value = compute_tooltip_attribute_value(consulted_nodes, current);
787 if let Some(tooltip_attribute_value) = tooltip_attribute_value {
788 consulted_nodes.push(current.clone());
789 return tooltip_attribute_value;
790 }
791
792 consulted_nodes.push(current.clone());
794 "".into()
795 }
796
797 as_flat_string(inner_compute_text_alternative(
798 compute,
799 hidden,
800 uncached_get_computed_style,
801 get_computed_style,
802 &mut consulted_nodes,
803 root,
804 ComputeTextAlternativeContext {
805 is_embedded_in_label: false,
806 is_referenced: compute == Compute::Description,
808 recursion: false,
809 },
810 ))
811}