1use crate::node::ElementData;
4use crate::{BaseDocument, Node as BlitzDomNode, local_name};
5use accesskit::{Node as AccessKitNode, NodeId, Role, Tree, TreeId, TreeUpdate};
6
7fn attr<'a>(element: &'a ElementData, name: &str) -> Option<&'a str> {
13 element
14 .attrs()
15 .iter()
16 .find(|attribute| attribute.name.local.as_ref() == name)
17 .map(|attribute| attribute.value.as_ref())
18}
19
20pub fn implicit_role(element: &ElementData) -> Role {
48 let name = element.name.local.as_ref();
49 match name {
50 "article" => Role::Article,
52 "aside" => Role::Complementary,
53 "footer" => Role::Footer,
54 "header" => Role::Header,
55 "main" => Role::Main,
56 "nav" => Role::Navigation,
57 "search" => Role::Search,
58 "section" => {
74 if ["aria-label", "aria-labelledby", "title"]
75 .iter()
76 .any(|name| attr(element, name).is_some_and(|value| !value.trim().is_empty()))
77 {
78 Role::Region
79 } else {
80 Role::Section
81 }
82 }
83 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading,
84 "p" => Role::Paragraph,
85 "blockquote" => Role::Blockquote,
86 "figure" => Role::Figure,
87 "figcaption" | "caption" => Role::Caption,
88 "hr" => Role::Splitter,
89
90 "ul" | "ol" | "menu" => Role::List,
92 "li" => Role::ListItem,
93 "dl" => Role::DescriptionList,
94 "dt" => Role::Term,
95 "dd" => Role::Definition,
96 "dialog" => Role::Dialog,
97 "fieldset" => Role::Group,
98 "form" => Role::Form,
99 "div" => Role::GenericContainer,
100
101 "table" => Role::Table,
103 "thead" | "tbody" | "tfoot" => Role::RowGroup,
104 "tr" => Role::Row,
105 "td" => Role::Cell,
106 "th" => match element.attr(local_name!("scope")) {
109 Some("row") | Some("rowgroup") => Role::RowHeader,
110 _ => Role::ColumnHeader,
111 },
112
113 "a" => match element.link_href() {
116 Some(_) => Role::Link,
117 None => Role::GenericContainer,
118 },
119 "button" => Role::Button,
120 "label" => Role::Label,
121 "legend" => Role::Label,
122 "select" => match element.attr(local_name!("multiple")) {
123 Some(_) => Role::ListBox,
124 None => Role::ComboBox,
125 },
126 "option" => Role::ListBoxOption,
127 "textarea" => Role::MultilineTextInput,
128 "progress" => Role::ProgressIndicator,
129 "meter" => Role::Meter,
130 "output" => Role::Status,
131 "summary" => Role::DisclosureTriangle,
132
133 "code" => Role::Code,
135 "em" => Role::Emphasis,
136 "strong" => Role::Strong,
137 "mark" => Role::Mark,
138 "time" => Role::Time,
139 "img" => Role::Image,
140 "iframe" => Role::Iframe,
141
142 "input" => {
143 let ty = element.attr(local_name!("type")).unwrap_or("text");
144 match ty {
145 "button" | "submit" | "reset" => Role::Button,
146 "checkbox" => Role::CheckBox,
147 "color" => Role::ColorWell,
148 "date" => Role::DateInput,
149 "datetime-local" => Role::DateTimeInput,
150 "email" => Role::EmailInput,
151 "number" => Role::NumberInput,
152 "password" => Role::PasswordInput,
153 "radio" => Role::RadioButton,
154 "range" => Role::Slider,
155 "search" => Role::SearchInput,
156 "tel" => Role::PhoneNumberInput,
157 "time" => Role::TimeInput,
158 _ => Role::TextInput,
159 }
160 }
161 _ => Role::Unknown,
162 }
163}
164
165impl BaseDocument {
166 pub fn build_accessibility_tree(&self) -> TreeUpdate {
167 let mut nodes = std::collections::HashMap::new();
168 let mut window = AccessKitNode::new(Role::Window);
169
170 self.visit_flattened(|node_id, parent_id, node| {
171 let parent = parent_id
172 .and_then(|parent_id| nodes.get_mut(&parent_id))
173 .map(|(_, parent)| parent)
174 .unwrap_or(&mut window);
175 let (id, builder) = self.build_accessibility_node(node, parent);
176
177 nodes.insert(node_id, (id, builder));
178 });
179
180 let mut nodes: Vec<_> = nodes
181 .into_iter()
182 .map(|(_, (id, node))| (id, node))
183 .collect();
184 nodes.push((NodeId(u64::MAX), window));
185
186 let tree = Tree::new(NodeId(u64::MAX));
187 TreeUpdate {
188 tree_id: TreeId::ROOT,
189 nodes,
190 tree: Some(tree),
191 focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
192 }
193 }
194
195 fn build_accessibility_node(
196 &self,
197 node: &BlitzDomNode,
198 parent: &mut AccessKitNode,
199 ) -> (NodeId, AccessKitNode) {
200 let id = NodeId(node.id.as_u64());
201
202 let mut builder = AccessKitNode::default();
203 if node.parent.is_none() {
204 builder.set_role(Role::Window)
205 } else if let Some(element_data) = node.element_data() {
206 builder.set_role(implicit_role(element_data));
207 match element_data.name.local.as_ref() {
210 "select" => builder.set_value(self.select_label(node.id)),
211 "option" => {
212 builder.set_label(self.option_label(node.id));
213 builder.set_selected(self.option_is_selected(node.id));
214 }
215 _ => {}
216 }
217 builder.set_html_tag(element_data.name.local.to_string());
218 } else if node.is_text_node() {
219 builder.set_role(Role::TextRun);
220 builder.set_value(node.text_content());
221 parent.push_labelled_by(id)
222 }
223
224 parent.push_child(id);
225
226 (id, builder)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use markup5ever::{QualName, ns};
233
234 use crate::node::Attribute;
235
236 use super::*;
237
238 fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData {
245 ElementData::new(
246 QualName::new(None, ns!(html), tag.into()),
247 attributes
248 .iter()
249 .map(|(name, value)| Attribute {
250 name: QualName::new(None, ns!(), (*name).into()),
251 value: (*value).into(),
252 })
253 .collect(),
254 )
255 }
256
257 fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role {
258 implicit_role(&element(tag, attributes))
259 }
260
261 #[test]
272 fn a_header_cell_is_a_header_and_scope_says_which_kind() {
273 assert_eq!(role_of("th", &[]), Role::ColumnHeader);
274 assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader);
275 assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader);
276 assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader);
277 assert_eq!(role_of("td", &[]), Role::Cell);
278 }
279
280 #[test]
281 fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() {
282 assert_eq!(
283 role_of("section", &[("aria-label", "connection settings")]),
284 Role::Region
285 );
286 assert_eq!(
287 role_of("section", &[("aria-labelledby", "heading")]),
288 Role::Region
289 );
290 assert_eq!(role_of("section", &[("title", "notes")]), Role::Region);
291 assert_eq!(role_of("section", &[]), Role::Section);
292 assert_eq!(
293 role_of("section", &[("aria-label", " ")]),
294 Role::Section,
295 "whitespace is not a name"
296 );
297 }
298
299 #[test]
300 fn an_anchor_is_a_link_only_when_it_goes_somewhere() {
301 assert_eq!(role_of("a", &[("href", "/x")]), Role::Link);
302 assert_eq!(role_of("a", &[]), Role::GenericContainer);
303 }
304
305 #[test]
306 fn an_input_takes_its_role_from_its_type() {
307 assert_eq!(role_of("input", &[]), Role::TextInput);
308 assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox);
309 assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton);
310 assert_eq!(role_of("input", &[("type", "range")]), Role::Slider);
311 assert_eq!(role_of("input", &[("type", "submit")]), Role::Button);
312 assert_eq!(
313 role_of("input", &[("type", "password")]),
314 Role::PasswordInput
315 );
316 assert_eq!(
317 role_of("input", &[("type", "not-a-type")]),
318 Role::TextInput,
319 "an unknown input type still edits text"
320 );
321 }
322
323 #[test]
324 fn a_multiple_select_is_a_list_box() {
325 assert_eq!(role_of("select", &[]), Role::ComboBox);
326 assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox);
327 }
328}