1use std::{collections::HashMap, sync::LazyLock};
2
3use web_sys::{wasm_bindgen::JsCast, Element, HtmlInputElement, HtmlSelectElement};
4
5use crate::util::PRESENTATION_ROLES;
6
7static LOCAL_NAME_TO_ROLE_MAPPINGS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
10 HashMap::from([
11 ("article".into(), "article".into()),
12 ("aside".into(), "complementary".into()),
13 ("button".into(), "button".into()),
14 ("datalist".into(), "listbox".into()),
15 ("dd".into(), "definition".into()),
16 ("details".into(), "group".into()),
17 ("dialog".into(), "dialog".into()),
18 ("dt".into(), "term".into()),
19 ("fieldset".into(), "group".into()),
20 ("figure".into(), "figure".into()),
21 ("form".into(), "form".into()),
23 ("footer".into(), "contentinfo".into()),
24 ("h1".into(), "heading".into()),
25 ("h2".into(), "heading".into()),
26 ("h3".into(), "heading".into()),
27 ("h4".into(), "heading".into()),
28 ("h5".into(), "heading".into()),
29 ("h6".into(), "heading".into()),
30 ("header".into(), "banner".into()),
31 ("hr".into(), "separator".into()),
32 ("html".into(), "document".into()),
33 ("legend".into(), "legend".into()),
34 ("li".into(), "listitem".into()),
35 ("math".into(), "math".into()),
36 ("main".into(), "main".into()),
37 ("menu".into(), "list".into()),
38 ("nav".into(), "navigation".into()),
39 ("ol".into(), "list".into()),
40 ("optgroup".into(), "group".into()),
41 ("option".into(), "option".into()),
43 ("output".into(), "status".into()),
44 ("progress".into(), "progressbar".into()),
45 ("section".into(), "region".into()),
47 ("summary".into(), "button".into()),
48 ("table".into(), "table".into()),
49 ("tbody".into(), "rowgroup".into()),
50 ("textarea".into(), "textbox".into()),
51 ("tfoot".into(), "rowgroup".into()),
52 ("td".into(), "cell".into()),
54 ("th".into(), "columnheader".into()),
55 ("thead".into(), "rowgroup".into()),
56 ("tr".into(), "row".into()),
57 ("ul".into(), "list".into()),
58 ])
59});
60
61const GLOBAL_ARIA_ATTRIBUTES: [&str; 18] = [
64 "aria-atomic",
65 "aria-busy",
66 "aria-controls",
67 "aria-current",
68 "aria-description",
69 "aria-describedby",
70 "aria-details",
71 "aria-dropeffect",
73 "aria-flowto",
75 "aria-grabbed",
76 "aria-hidden",
78 "aria-keyshortcuts",
80 "aria-label",
81 "aria-labelledby",
82 "aria-live",
83 "aria-owns",
84 "aria-relevant",
85 "aria-roledescription",
86];
87
88static PROHIBITED_ATTRIBUTES: LazyLock<HashMap<String, Vec<String>>> = LazyLock::new(|| {
89 HashMap::from([
90 (
91 "caption".to_string(),
92 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
93 ),
94 (
95 "code".to_string(),
96 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
97 ),
98 (
99 "deletion".to_string(),
100 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
101 ),
102 (
103 "emphasis".to_string(),
104 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
105 ),
106 (
107 "generic".to_string(),
108 vec![
109 "aria-label".to_string(),
110 "aria-labelledby".to_string(),
111 "aria-roledescription".to_string(),
112 ],
113 ),
114 (
115 "insertion".to_string(),
116 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
117 ),
118 (
119 "none".to_string(),
120 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
121 ),
122 (
123 "paragraph".to_string(),
124 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
125 ),
126 (
127 "presentation".to_string(),
128 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
129 ),
130 (
131 "strong".to_string(),
132 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
133 ),
134 (
135 "subscript".to_string(),
136 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
137 ),
138 (
139 "superscript".to_string(),
140 vec!["aria-label".to_string(), "aria-labelledby".to_string()],
141 ),
142 ])
143});
144
145fn has_global_aria_attributes(element: &Element, role: String) -> bool {
146 GLOBAL_ARIA_ATTRIBUTES.iter().any(|attribute_name| {
147 element.has_attribute(attribute_name)
148 && !PROHIBITED_ATTRIBUTES
149 .get(&role)
150 .is_some_and(|attributes| attributes.contains(&attribute_name.to_string()))
151 })
152}
153
154fn ignore_presentational_role(element: &Element, implicit_role: String) -> bool {
155 has_global_aria_attributes(element, implicit_role)
157}
158
159pub fn get_role(element: &Element) -> Option<String> {
160 let explicit_role = get_explicit_role(element);
161 if explicit_role.is_none()
162 || explicit_role
163 .as_ref()
164 .is_some_and(|explicit_role| PRESENTATION_ROLES.contains(&explicit_role.as_str()))
165 {
166 let implicit_role = get_implicit_role(element);
167 if explicit_role.is_none()
168 || ignore_presentational_role(element, implicit_role.clone().unwrap_or("".into()))
169 {
170 return implicit_role;
171 }
172 }
173
174 explicit_role
175}
176
177fn get_implicit_role(element: &Element) -> Option<String> {
178 let local_name = element.local_name();
179
180 if let Some(mapped_by_tag) = LOCAL_NAME_TO_ROLE_MAPPINGS.get(&local_name) {
181 return Some(mapped_by_tag.clone());
182 }
183
184 match local_name.as_str() {
185 "a" | "area" | "link" => element.has_attribute("href").then_some("link".into()),
186 "img" => {
187 if element.get_attribute("alt") == Some("".into())
188 && !ignore_presentational_role(element, "img".into())
189 {
190 Some("presentation".into())
191 } else {
192 Some("img".into())
193 }
194 }
195 "input" => {
196 let r#type = element.unchecked_ref::<HtmlInputElement>().type_();
197 match r#type.as_str() {
198 "button" | "image" | "reset" | "submit" => Some("button".into()),
199 "checkbox" | "radio" => Some(r#type),
200 "range" => Some("slider".into()),
201 "email" | "tel" | "text" | "url" => {
202 if element.has_attribute("list") {
203 Some("combobox".into())
204 } else {
205 Some("textbox".into())
206 }
207 }
208 "search" => {
209 if element.has_attribute("list") {
210 Some("combobox".into())
211 } else {
212 Some("searchbox".into())
213 }
214 }
215 "number" => Some("spinbutton".into()),
216 _ => None,
217 }
218 }
219 "select" => {
220 if element.has_attribute("multiple")
221 && element.unchecked_ref::<HtmlSelectElement>().size() > 1
222 {
223 Some("listbox".into())
224 } else {
225 Some("combobox".into())
226 }
227 }
228 _ => None,
229 }
230}
231
232fn get_explicit_role(element: &Element) -> Option<String> {
233 element.get_attribute("role").and_then(|role| {
234 role.trim().split(' ').next().and_then(|explicit_role| {
235 (!explicit_role.is_empty()).then_some(explicit_role.to_string())
236 })
237 })
238}