use crate::node::ElementData;
use crate::{BaseDocument, Node as BlitzDomNode, local_name};
use accesskit::{Node as AccessKitNode, NodeId, Role, Tree, TreeId, TreeUpdate};
fn attr<'a>(element: &'a ElementData, name: &str) -> Option<&'a str> {
element
.attrs()
.iter()
.find(|attribute| attribute.name.local.as_ref() == name)
.map(|attribute| attribute.value.as_ref())
}
pub fn implicit_role(element: &ElementData) -> Role {
let name = element.name.local.as_ref();
match name {
"article" => Role::Article,
"aside" => Role::Complementary,
"footer" => Role::Footer,
"header" => Role::Header,
"main" => Role::Main,
"nav" => Role::Navigation,
"search" => Role::Search,
"section" => {
if ["aria-label", "aria-labelledby", "title"]
.iter()
.any(|name| attr(element, name).is_some_and(|value| !value.trim().is_empty()))
{
Role::Region
} else {
Role::Section
}
}
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading,
"p" => Role::Paragraph,
"blockquote" => Role::Blockquote,
"figure" => Role::Figure,
"figcaption" | "caption" => Role::Caption,
"hr" => Role::Splitter,
"ul" | "ol" | "menu" => Role::List,
"li" => Role::ListItem,
"dl" => Role::DescriptionList,
"dt" => Role::Term,
"dd" => Role::Definition,
"dialog" => Role::Dialog,
"fieldset" => Role::Group,
"form" => Role::Form,
"div" => Role::GenericContainer,
"table" => Role::Table,
"thead" | "tbody" | "tfoot" => Role::RowGroup,
"tr" => Role::Row,
"td" => Role::Cell,
"th" => match element.attr(local_name!("scope")) {
Some("row") | Some("rowgroup") => Role::RowHeader,
_ => Role::ColumnHeader,
},
"a" => match element.attr(local_name!("href")) {
Some(_) => Role::Link,
None => Role::GenericContainer,
},
"button" => Role::Button,
"label" => Role::Label,
"legend" => Role::Label,
"select" => match element.attr(local_name!("multiple")) {
Some(_) => Role::ListBox,
None => Role::ComboBox,
},
"option" => Role::ListBoxOption,
"textarea" => Role::MultilineTextInput,
"progress" => Role::ProgressIndicator,
"meter" => Role::Meter,
"output" => Role::Status,
"summary" => Role::DisclosureTriangle,
"code" => Role::Code,
"em" => Role::Emphasis,
"strong" => Role::Strong,
"mark" => Role::Mark,
"time" => Role::Time,
"img" => Role::Image,
"iframe" => Role::Iframe,
"input" => {
let ty = element.attr(local_name!("type")).unwrap_or("text");
match ty {
"button" | "submit" | "reset" => Role::Button,
"checkbox" => Role::CheckBox,
"color" => Role::ColorWell,
"date" => Role::DateInput,
"datetime-local" => Role::DateTimeInput,
"email" => Role::EmailInput,
"number" => Role::NumberInput,
"password" => Role::PasswordInput,
"radio" => Role::RadioButton,
"range" => Role::Slider,
"search" => Role::SearchInput,
"tel" => Role::PhoneNumberInput,
"time" => Role::TimeInput,
_ => Role::TextInput,
}
}
_ => Role::Unknown,
}
}
impl BaseDocument {
pub fn build_accessibility_tree(&self) -> TreeUpdate {
let mut nodes = std::collections::HashMap::new();
let mut window = AccessKitNode::new(Role::Window);
self.visit(|node_id, node| {
let parent = node
.parent
.and_then(|parent_id| nodes.get_mut(&parent_id))
.map(|(_, parent)| parent)
.unwrap_or(&mut window);
let (id, builder) = self.build_accessibility_node(node, parent);
nodes.insert(node_id, (id, builder));
});
let mut nodes: Vec<_> = nodes
.into_iter()
.map(|(_, (id, node))| (id, node))
.collect();
nodes.push((NodeId(u64::MAX), window));
let tree = Tree::new(NodeId(u64::MAX));
TreeUpdate {
tree_id: TreeId::ROOT,
nodes,
tree: Some(tree),
focus: NodeId(self.focus_node_id.map(|id| id.as_u64()).unwrap_or(u64::MAX)),
}
}
fn build_accessibility_node(
&self,
node: &BlitzDomNode,
parent: &mut AccessKitNode,
) -> (NodeId, AccessKitNode) {
let id = NodeId(node.id.as_u64());
let mut builder = AccessKitNode::default();
if node.parent.is_none() {
builder.set_role(Role::Window)
} else if let Some(element_data) = node.element_data() {
builder.set_role(implicit_role(element_data));
match element_data.name.local.as_ref() {
"select" => builder.set_value(self.select_label(node.id)),
"option" => {
builder.set_label(self.option_label(node.id));
builder.set_selected(self.option_is_selected(node.id));
}
_ => {}
}
builder.set_html_tag(element_data.name.local.to_string());
} else if node.is_text_node() {
builder.set_role(Role::TextRun);
builder.set_value(node.text_content());
parent.push_labelled_by(id)
}
parent.push_child(id);
(id, builder)
}
}
#[cfg(test)]
mod tests {
use markup5ever::{QualName, ns};
use crate::node::Attribute;
use super::*;
fn element(tag: &str, attributes: &[(&str, &str)]) -> ElementData {
ElementData::new(
QualName::new(None, ns!(html), tag.into()),
attributes
.iter()
.map(|(name, value)| Attribute {
name: QualName::new(None, ns!(), (*name).into()),
value: (*value).into(),
})
.collect(),
)
}
fn role_of(tag: &str, attributes: &[(&str, &str)]) -> Role {
implicit_role(&element(tag, attributes))
}
#[test]
fn a_header_cell_is_a_header_and_scope_says_which_kind() {
assert_eq!(role_of("th", &[]), Role::ColumnHeader);
assert_eq!(role_of("th", &[("scope", "col")]), Role::ColumnHeader);
assert_eq!(role_of("th", &[("scope", "row")]), Role::RowHeader);
assert_eq!(role_of("th", &[("scope", "rowgroup")]), Role::RowHeader);
assert_eq!(role_of("td", &[]), Role::Cell);
}
#[test]
fn a_named_section_is_a_landmark_and_an_unnamed_one_is_not() {
assert_eq!(
role_of("section", &[("aria-label", "connection settings")]),
Role::Region
);
assert_eq!(
role_of("section", &[("aria-labelledby", "heading")]),
Role::Region
);
assert_eq!(role_of("section", &[("title", "notes")]), Role::Region);
assert_eq!(role_of("section", &[]), Role::Section);
assert_eq!(
role_of("section", &[("aria-label", " ")]),
Role::Section,
"whitespace is not a name"
);
}
#[test]
fn an_anchor_is_a_link_only_when_it_goes_somewhere() {
assert_eq!(role_of("a", &[("href", "/x")]), Role::Link);
assert_eq!(role_of("a", &[]), Role::GenericContainer);
}
#[test]
fn an_input_takes_its_role_from_its_type() {
assert_eq!(role_of("input", &[]), Role::TextInput);
assert_eq!(role_of("input", &[("type", "checkbox")]), Role::CheckBox);
assert_eq!(role_of("input", &[("type", "radio")]), Role::RadioButton);
assert_eq!(role_of("input", &[("type", "range")]), Role::Slider);
assert_eq!(role_of("input", &[("type", "submit")]), Role::Button);
assert_eq!(
role_of("input", &[("type", "password")]),
Role::PasswordInput
);
assert_eq!(
role_of("input", &[("type", "not-a-type")]),
Role::TextInput,
"an unknown input type still edits text"
);
}
#[test]
fn a_multiple_select_is_a_list_box() {
assert_eq!(role_of("select", &[]), Role::ComboBox);
assert_eq!(role_of("select", &[("multiple", "")]), Role::ListBox);
}
}