mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
429 lines
15 KiB
Rust
429 lines
15 KiB
Rust
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"));
|
|
|
|
// TODO: The roles of elements with strong native semantics cannot be overridden; see
|
|
// https://www.w3.org/TR/wai-aria-1.2/#host_general_conflict.
|
|
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);
|
|
|
|
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
|
|
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>()
|
|
// Accesskit levels are 0-based, while ARIA levels are 1-based
|
|
// See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
|
|
&& 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 {
|
|
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
|
|
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> {
|
|
// Accesskit levels are 0-based, while ARIA levels are 1-based
|
|
// See https://docs.rs/accesskit/latest/accesskit/struct.Node.html#method.level
|
|
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 {
|
|
// ARIA specifies an implicit level of 2 for the heading role. Accesskit levels are 0-based,
|
|
// so this turns into level 1
|
|
Role::Heading => Some(1),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn role_from_element_data(element_data: &ElementData) -> Option<Role> {
|
|
// <https://www.w3.org/TR/html-aam-1.0/>
|
|
match &*element_data.name.local {
|
|
// Document structure
|
|
"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),
|
|
|
|
// Grouping
|
|
"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),
|
|
|
|
// Tables
|
|
"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),
|
|
},
|
|
|
|
// Interactive
|
|
// An <a> is only a link when it has an href.
|
|
"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),
|
|
|
|
// Inline semantics
|
|
"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,
|
|
}
|
|
}
|