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.attr(local_name!("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(|node_id, node| {
171 let parent = node
172 .parent
173 .and_then(|parent_id| nodes.get_mut(&parent_id))
174 .map(|(_, parent)| parent)
175 .unwrap_or(&mut window);
176 let (id, builder) = self.build_accessibility_node(node, parent);
177
178 nodes.insert(node_id, (id, builder));
179 });
180
181 let mut nodes: Vec<_> = nodes
182 .into_iter()
183 .map(|(_, (id, node))| (id, node))
184 .collect();
185 nodes.push((NodeId(u64::MAX), window));
186
187 let tree = Tree::new(NodeId(u64::MAX));
188 TreeUpdate {
189 tree_id: TreeId::ROOT,
190 nodes,
191 tree: Some(tree),
192 focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
193 }
194 }
195
196 fn build_accessibility_node(
197 &self,
198 node: &BlitzDomNode,
199 parent: &mut AccessKitNode,
200 ) -> (NodeId, AccessKitNode) {
201 let id = NodeId(node.id.as_u64());
202
203 let mut builder = AccessKitNode::default();
204 if node.parent.is_none() {
205 builder.set_role(Role::Window)
206 } else if let Some(element_data) = node.element_data() {
207 builder.set_role(implicit_role(element_data));
208 match element_data.name.local.as_ref() {
211 "select" => builder.set_value(self.select_label(node.id)),
212 "option" => {
213 builder.set_label(self.option_label(node.id));
214 builder.set_selected(self.option_is_selected(node.id));
215 }
216 _ => {}
217 }
218 builder.set_html_tag(element_data.name.local.to_string());
219 } else if node.is_text_node() {
220 builder.set_role(Role::TextRun);
221 builder.set_value(node.text_content());
222 parent.push_labelled_by(id)
223 }
224
225 parent.push_child(id);
226
227 (id, builder)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use markup5ever::{QualName, ns};
234
235 use crate::node::Attribute;
236
237 use super::*;
238
239 fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData {
246 ElementData::new(
247 QualName::new(None, ns!(html), tag.into()),
248 attributes
249 .iter()
250 .map(|(name, value)| Attribute {
251 name: QualName::new(None, ns!(), (*name).into()),
252 value: (*value).into(),
253 })
254 .collect(),
255 )
256 }
257
258 fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role {
259 implicit_role(&element(tag, attributes))
260 }
261
262 #[test]
273 fn a_header_cell_is_a_header_and_scope_says_which_kind() {
274 assert_eq!(role_of("th", &[]), Role::ColumnHeader);
275 assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader);
276 assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader);
277 assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader);
278 assert_eq!(role_of("td", &[]), Role::Cell);
279 }
280
281 #[test]
282 fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() {
283 assert_eq!(
284 role_of("section", &[("aria-label", "connection settings")]),
285 Role::Region
286 );
287 assert_eq!(
288 role_of("section", &[("aria-labelledby", "heading")]),
289 Role::Region
290 );
291 assert_eq!(role_of("section", &[("title", "notes")]), Role::Region);
292 assert_eq!(role_of("section", &[]), Role::Section);
293 assert_eq!(
294 role_of("section", &[("aria-label", " ")]),
295 Role::Section,
296 "whitespace is not a name"
297 );
298 }
299
300 #[test]
301 fn an_anchor_is_a_link_only_when_it_goes_somewhere() {
302 assert_eq!(role_of("a", &[("href", "/x")]), Role::Link);
303 assert_eq!(role_of("a", &[]), Role::GenericContainer);
304 }
305
306 #[test]
307 fn an_input_takes_its_role_from_its_type() {
308 assert_eq!(role_of("input", &[]), Role::TextInput);
309 assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox);
310 assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton);
311 assert_eq!(role_of("input", &[("type", "range")]), Role::Slider);
312 assert_eq!(role_of("input", &[("type", "submit")]), Role::Button);
313 assert_eq!(
314 role_of("input", &[("type", "password")]),
315 Role::PasswordInput
316 );
317 assert_eq!(
318 role_of("input", &[("type", "not-a-type")]),
319 Role::TextInput,
320 "an unknown input type still edits text"
321 );
322 }
323
324 #[test]
325 fn a_multiple_select_is_a_list_box() {
326 assert_eq!(role_of("select", &[]), Role::ComboBox);
327 assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox);
328 }
329}