dom_accessibility_api/
get_role.rs

1use std::{collections::HashMap, sync::LazyLock};
2
3use web_sys::{wasm_bindgen::JsCast, Element, HtmlInputElement, HtmlSelectElement};
4
5use crate::util::PRESENTATION_ROLES;
6
7// https://w3c.github.io/html-aria/#document-conformance-requirements-for-use-of-aria-attributes-in-html
8
9static 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        // Warning: Only with an accessible name.
22        ("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        // Warning: Only in certain context.
42        ("option".into(), "option".into()),
43        ("output".into(), "status".into()),
44        ("progress".into(), "progressbar".into()),
45        // Warning: Only with an accessible name.
46        ("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        // Warning: Only in certain context.
53        ("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
61// https://rawgit.com/w3c/aria/stable/#global_states
62// Commented attributes are deprecated.
63const 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    // "disabled",
72    "aria-dropeffect",
73    // "errormessage",
74    "aria-flowto",
75    "aria-grabbed",
76    // "haspopup",
77    "aria-hidden",
78    // "invalid",
79    "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    // https://rawgit.com/w3c/aria/stable/#conflict_resolution_presentation_none
156    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}