use crate::{
BaseDocument, ElementData, Node as BlitzDomNode, NodeData, NodeId as BlitzDomNodeId, local_name,
};
use accesskit::{Node as AccessKitNode, NodeId, Role, TreeId, TreeInfo, TreeUpdate};
use style::properties::longhands::visibility;
impl BaseDocument {
pub fn build_accessibility_tree(&self) -> TreeUpdate {
let mut nodes = std::collections::HashMap::new();
let mut window = AccessKitNode::new(Role::Window);
let mut hidden_nodes = std::collections::HashSet::new();
let mut labelled_by_nodes = Vec::new();
let mut described_by_nodes = Vec::new();
self.visit(|node_id, node| {
if node.is_hidden_from_accessibility_tree()
|| node
.parent
.map(|p| hidden_nodes.contains(&p))
.unwrap_or(false)
{
hidden_nodes.insert(node_id);
return;
}
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);
if node.attr(local_name!("aria-labelledby")).is_some() {
labelled_by_nodes.push(node_id);
}
if node.attr(local_name!("aria-describedby")).is_some() {
described_by_nodes.push(node_id);
}
nodes.insert(node_id, (id, builder));
});
let mut label = FlatText::default();
for node_id in labelled_by_nodes {
let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-labelledby")) else {
continue;
};
let Some((id, mut builder)) = nodes.remove(&node_id) else {
continue;
};
let mut has_target = false;
label.clear();
for target in self.referenced_dom_ids_to_node_ids(id_refs) {
if !has_target {
builder.clear_labelled_by();
has_target = true;
}
if target == node_id || nodes.contains_key(&target) {
builder.push_labelled_by(NodeId(target.as_u64()));
}
self.nodes[target].write_text_alternative(&mut label);
label.break_word();
}
if !label.text.is_empty() {
builder.set_label(label.text.as_str());
}
nodes.insert(node_id, (id, builder));
}
for node_id in described_by_nodes {
let Some(id_refs) = self.nodes[node_id].attr(local_name!("aria-describedby")) else {
continue;
};
let Some((id, mut builder)) = nodes.remove(&node_id) else {
continue;
};
for target in self.referenced_dom_ids_to_node_ids(id_refs) {
if target == node_id || nodes.contains_key(&target) {
builder.push_described_by(NodeId(target.as_u64()));
}
}
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 = TreeInfo::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 referenced_dom_ids_to_node_ids<'a>(
&'a self,
nodes_reference: &'a str,
) -> impl Iterator<Item = BlitzDomNodeId> + 'a {
nodes_reference
.split_ascii_whitespace()
.filter_map(|dom_id| self.nodes_to_id.get(dom_id)?.first().copied())
}
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() {
let name = element_data.name.local.to_string();
let role_attr = element_data.attr(local_name!("role"));
let role = role_attr
.and_then(role_from_name)
.or_else(|| role_from_element_data(element_data))
.unwrap_or(Role::Unknown);
builder.set_role(role);
if element_data.attr(local_name!("aria-hidden")) == Some("true") {
builder.set_hidden();
}
if let Some(aria_expanded) = element_data.attr(local_name!("aria-expanded"))
&& let Ok(aria_expanded) = aria_expanded.parse()
{
builder.set_expanded(aria_expanded);
}
if element_data.attr(local_name!("aria-disabled")) == Some("true") {
builder.set_disabled();
}
if let Some(aria_selected) = element_data.attr(local_name!("aria-selected"))
&& let Ok(aria_selected) = aria_selected.parse()
{
builder.set_selected(aria_selected);
}
if let Some(aria_label) = element_data.attr(local_name!("aria-label")) {
builder.set_label(aria_label);
}
if let Some(aria_description) = element_data.attr(local_name!("aria-description")) {
builder.set_description(aria_description);
}
if let Some(aria_level) = element_data.attr(local_name!("aria-level"))
&& let Ok(aria_level) = aria_level.parse::<usize>()
&& let Some(aria_level) = aria_level.checked_sub(1)
{
builder.set_level(aria_level);
} else if let Some(default_level) = default_level_for_tag(&name) {
builder.set_level(default_level);
} else if let Some(default_level) = default_level_for_role(role) {
builder.set_level(default_level);
}
builder.set_html_tag(name);
} 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)
}
}
impl BlitzDomNode {
fn is_hidden_from_accessibility_tree(&self) -> bool {
self.try_stylo_element_data()
.as_ref()
.and_then(|s| s.get())
.map(|s| {
s.styles.is_display_none()
|| s.styles.primary().clone_visibility()
== visibility::computed_value::T::Hidden
})
.unwrap_or(false)
}
fn write_text_alternative(&self, out: &mut FlatText) {
match self
.attr(local_name!("aria-label"))
.filter(|aria_label| !aria_label.trim_ascii().is_empty())
{
Some(aria_label) => out.push(aria_label),
None => self.write_flat_text_content(out),
}
}
fn write_flat_text_content(&self, out: &mut FlatText) {
match &self.data {
NodeData::Text(data) => out.push(&data.content),
NodeData::Element(..) | NodeData::AnonymousBlock(..) => {
for &child_id in &self.children {
self.with(child_id).write_flat_text_content(out);
}
}
_ => {}
}
}
}
#[derive(Default)]
struct FlatText {
text: String,
pending_space: bool,
}
impl FlatText {
fn push(&mut self, text: &str) {
for (i, word) in text.split(|c: char| c.is_ascii_whitespace()).enumerate() {
self.pending_space |= i > 0;
if word.is_empty() {
continue;
}
if self.pending_space && !self.text.is_empty() {
self.text.push(' ');
}
self.pending_space = false;
self.text.push_str(word);
}
}
fn break_word(&mut self) {
self.pending_space = true;
}
fn clear(&mut self) {
self.text.clear();
self.pending_space = false;
}
}
fn role_from_name(name: &str) -> Option<Role> {
match name {
"alert" => Some(Role::Alert),
"alertdialog" => Some(Role::AlertDialog),
"button" => Some(Role::Button),
"checkbox" => Some(Role::CheckBox),
"dialog" => Some(Role::Dialog),
"gridcell" => Some(Role::GridCell),
"link" => Some(Role::Link),
"log" => Some(Role::Log),
"marquee" => Some(Role::Marquee),
"menuitem" => Some(Role::MenuItem),
"menuitemcheckbox" => Some(Role::MenuItemCheckBox),
"menuitemradio" => Some(Role::MenuItemRadio),
"option" => Some(Role::ListBoxOption),
"progressbar" => Some(Role::ProgressIndicator),
"radio" => Some(Role::RadioButton),
"scrollbar" => Some(Role::ScrollBar),
"slider" => Some(Role::Slider),
"spinbutton" => Some(Role::SpinButton),
"status" => Some(Role::Status),
"tab" => Some(Role::Tab),
"tabpanel" => Some(Role::TabPanel),
"textbox" => Some(Role::TextInput),
"timer" => Some(Role::Timer),
"tooltip" => Some(Role::Tooltip),
"treeitem" => Some(Role::TreeItem),
"combobox" => Some(Role::ComboBox),
"grid" => Some(Role::Grid),
"listbox" => Some(Role::ListBox),
"menu" => Some(Role::Menu),
"menubar" => Some(Role::MenuBar),
"radiogroup" => Some(Role::RadioGroup),
"tablist" => Some(Role::TabList),
"tree" => Some(Role::Tree),
"treegrid" => Some(Role::TreeGrid),
"article" => Some(Role::Article),
"columnheader" => Some(Role::ColumnHeader),
"definition" => Some(Role::Definition),
"document" => Some(Role::Document),
"group" => Some(Role::Group),
"heading" => Some(Role::Heading),
"img" => Some(Role::Image),
"list" => Some(Role::List),
"listitem" => Some(Role::ListItem),
"math" => Some(Role::Math),
"note" => Some(Role::Note),
"region" => Some(Role::Region),
"row" => Some(Role::Row),
"rowgroup" => Some(Role::RowGroup),
"rowheader" => Some(Role::RowHeader),
"toolbar" => Some(Role::Toolbar),
"application" => Some(Role::Application),
"banner" => Some(Role::Banner),
"complementary" => Some(Role::Complementary),
"contentinfo" => Some(Role::ContentInfo),
"form" => Some(Role::Form),
"main" => Some(Role::Main),
"navigation" => Some(Role::Navigation),
"search" => Some(Role::Search),
_ => None,
}
}
fn default_level_for_tag(name: &str) -> Option<usize> {
match name {
"h1" => Some(0),
"h2" => Some(1),
"h3" => Some(2),
"h4" => Some(3),
"h5" => Some(4),
"h6" => Some(5),
_ => None,
}
}
fn default_level_for_role(role: Role) -> Option<usize> {
match role {
Role::Heading => Some(1),
_ => None,
}
}
fn role_from_element_data(element_data: &ElementData) -> Option<Role> {
match &*element_data.name.local {
"article" => Some(Role::Article),
"aside" => Some(Role::Complementary),
"footer" => Some(Role::Footer),
"header" => Some(Role::Header),
"main" => Some(Role::Main),
"nav" => Some(Role::Navigation),
"search" => Some(Role::Search),
"section" => Some(Role::Section),
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Some(Role::Heading),
"p" => Some(Role::Paragraph),
"blockquote" => Some(Role::Blockquote),
"figure" => Some(Role::Figure),
"figcaption" | "caption" => Some(Role::Caption),
"hr" => Some(Role::Splitter),
"ul" | "ol" | "menu" => Some(Role::List),
"li" => Some(Role::ListItem),
"dl" => Some(Role::DescriptionList),
"dt" => Some(Role::Term),
"dd" => Some(Role::Definition),
"dialog" => Some(Role::Dialog),
"fieldset" => Some(Role::Group),
"form" => Some(Role::Form),
"div" => Some(Role::GenericContainer),
"table" => Some(Role::Table),
"thead" | "tbody" | "tfoot" => Some(Role::RowGroup),
"tr" => Some(Role::Row),
"td" => Some(Role::Cell),
"th" => match element_data.attr(local_name!("scope")) {
Some("row") | Some("rowgroup") => Some(Role::RowHeader),
_ => Some(Role::ColumnHeader),
},
"a" => match element_data.attr(local_name!("href")) {
Some(_) => Some(Role::Link),
None => Some(Role::GenericContainer),
},
"button" => Some(Role::Button),
"label" => Some(Role::Label),
"legend" => Some(Role::Label),
"select" => match element_data.attr(local_name!("multiple")) {
Some(_) => Some(Role::ListBox),
None => Some(Role::ComboBox),
},
"option" => Some(Role::ListBoxOption),
"textarea" => Some(Role::MultilineTextInput),
"progress" => Some(Role::ProgressIndicator),
"meter" => Some(Role::Meter),
"output" => Some(Role::Status),
"summary" => Some(Role::DisclosureTriangle),
"code" => Some(Role::Code),
"em" => Some(Role::Emphasis),
"strong" => Some(Role::Strong),
"mark" => Some(Role::Mark),
"time" => Some(Role::Time),
"img" => Some(Role::Image),
"iframe" => Some(Role::Iframe),
"input" => {
let ty = element_data.attr(local_name!("type")).unwrap_or("text");
match ty {
"button" | "submit" | "reset" => Some(Role::Button),
"checkbox" => Some(Role::CheckBox),
"color" => Some(Role::ColorWell),
"date" => Some(Role::DateInput),
"datetime-local" => Some(Role::DateTimeInput),
"email" => Some(Role::EmailInput),
"number" => Some(Role::NumberInput),
"password" => Some(Role::PasswordInput),
"radio" => Some(Role::RadioButton),
"range" => Some(Role::Slider),
"search" => Some(Role::SearchInput),
"tel" => Some(Role::PhoneNumberInput),
"time" => Some(Role::TimeInput),
_ => Some(Role::TextInput),
}
}
_ => None,
}
}