diff --git a/Cargo.toml b/Cargo.toml index 81f1c6c..a534be2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/tauri-runtime-blitz"] [workspace.package] -version = "0.3.7" +version = "0.3.8" edition = "2024" license = "MIT OR Apache-2.0" publish = true diff --git a/crates/tauri-runtime-blitz/src/agent.rs b/crates/tauri-runtime-blitz/src/agent.rs index 785784d..d72c970 100644 --- a/crates/tauri-runtime-blitz/src/agent.rs +++ b/crates/tauri-runtime-blitz/src/agent.rs @@ -490,8 +490,20 @@ pub(crate) fn element_attr<'a>(element: &'a blitz_dom::ElementData, name: &str) #[cfg(all(feature = "agent-control", unix))] pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { + semantic_role_ref(element).to_owned() +} + +/// The same answer without owning it. +/// +/// A text node asks every element above it whether that element is already +/// named by the words in question, and the owned form allocated a `String` per +/// ancestor per text node to be compared against a fixed list and dropped. The +/// role is either a `&'static str` or the `role` attribute's own text, so +/// nothing here needs a copy. +#[cfg(all(feature = "agent-control", unix))] +pub(crate) fn semantic_role_ref(element: &blitz_dom::ElementData) -> &str { if let Some(role) = element_attr(element, "role") { - return role.into(); + return role; } let tag = element.name.local.as_ref(); match tag { @@ -503,12 +515,51 @@ pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { "img" => "img", "nav" => "navigation", "main" => "main", + // A named section is a landmark; an unnamed one is nothing. + // + // HTML-AAM: `
` maps to `region` when it has an accessible + // name, and to `generic` otherwise. Both halves matter. A named section + // is how a page says "this part is the connection settings", and it + // arrived indistinguishable from the `
`s around it; an unnamed one + // is a wrapper, and promoting those would put a landmark around every + // block on a page that reaches for `
` as a synonym for `
`. + // + // Attributes only, because the name has not been computed yet at this + // point and computing it here would walk the section's whole subtree for + // every element in the document. That is the same set an accessible name + // can come from for a container: `aria-labelledby` is included so an + // author who names a section that way still gets the landmark, and + // `semantic_name` resolves that reference to the name itself. + "section" + if ["aria-label", "aria-labelledby", "title"] + .iter() + .any(|name| { + element_attr(element, name).is_some_and(|value| !value.trim().is_empty()) + }) => + { + "region" + } "form" => "form", "ul" | "ol" => "list", "li" => "listitem", "table" => "table", "tr" => "row", - "td" | "th" => "cell", + "td" => "cell", + // A header cell is not a cell. + // + // HTML-AAM maps `` to `columnheader` or `rowheader`, and blitz-dom's + // own accessibility tree already does exactly this, so the two trees + // disagreed about the same document. What a header is for is saying + // which column or row the values under it belong to, and a check that + // wants "the Version column" has nothing to ask for while every header + // is spelled the same as the data beneath it. + // + // `scope` decides. Without one this is a column header, which is the + // common case (a `` row) and what blitz-dom falls back to. + "th" => match element_attr(element, "scope") { + Some("row") | Some("rowgroup") => "rowheader", + _ => "columnheader", + }, "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "heading", "input" => match element_attr(element, "type").unwrap_or("text") { "checkbox" => "checkbox", @@ -519,7 +570,6 @@ pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { }, _ => "generic", } - .into() } /// Where the labels are, so a control can be asked what names it. @@ -540,12 +590,23 @@ pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { /// Built once per snapshot rather than searched per node, because the lookup is /// "which label points at me" and answering that from the control costs a scan /// of the document each time. +/// +/// `aria-labelledby` is resolved from here too, for the same reason and against +/// the same single pass: it points at other elements by `id`, and finding them +/// from the referring node is a document scan per reference. #[cfg(all(feature = "agent-control", unix))] pub(crate) struct LabelIndex { /// The `for` attribute's value, to that label's text. by_control_id: std::collections::HashMap, /// Labels by node id, so an ancestor walk can recognise one it is inside. labels: std::collections::HashMap, + /// Every `id` in the document, to the node carrying it. + /// + /// The node rather than its text, because the text a reference resolves to + /// is only ever read for the few elements that carry an `aria-labelledby`. + /// Naming every element in the document up front to answer a question almost + /// none of them ask would walk each subtree twice per snapshot. + by_dom_id: std::collections::HashMap, } #[cfg(all(feature = "agent-control", unix))] @@ -553,14 +614,26 @@ impl LabelIndex { pub(crate) fn build(document: &blitz_dom::BaseDocument) -> Self { let mut by_control_id = std::collections::HashMap::new(); let mut labels = std::collections::HashMap::new(); + let mut by_dom_id = std::collections::HashMap::new(); for (id, node) in document.tree().iter() { let Some(element) = node.element_data() else { continue; }; + // The first one wins, which is what `getElementById` answers when a + // document repeats an `id`. Duplicate ids are invalid markup and a + // reference to one is ambiguous by construction, so the rule here is + // only about answering the same way twice. + if let Some(dom_id) = element_attr(element, "id") { + by_dom_id.entry(dom_id.to_owned()).or_insert(id); + } if element.name.local.as_ref() != "label" { continue; } - let text = node.text_content(); + // Read the same way a name is, not with `textContent`. A label is a + // name once it reaches a control, so a stylesheet inside it, or the + // half of a responsive label that is not rendered at this width, + // has to be left out here too. + let text = name_text(node, document); if let Some(control) = element_attr(element, "for") { by_control_id.insert(control.to_owned(), text.clone()); } @@ -569,7 +642,81 @@ impl LabelIndex { Self { by_control_id, labels, + by_dom_id, + } + } + + /// The name an element gives itself by pointing at other elements. + /// + /// # Why this exists + /// + /// `aria-labelledby` was not followed at all, so an element named that way + /// arrived anonymous. Two reports, independently: a QA agent giving a + /// dashboard card an identity on ui-starter-app found the region came back + /// unnamed and had to fall back to `aria-label`, and the `
` rule + /// beside this one reads `aria-labelledby` to decide the element is a + /// `region`, so such a section got the landmark role and an empty name, + /// which is worse than the wrapper it used to be reported as. + /// + /// # What it computes + /// + /// The attribute is a space-separated list of ids. Each referenced element's + /// rendered text is read in the order the ids are written, and the results + /// are joined by a single space. + /// + /// # Hidden referenced elements + /// + /// A referenced element contributes its text **even when it is not + /// rendered**, which is the opposite of the rule `name_text` applies inside + /// a subtree. Both are accname: a node that is hidden and *not* referenced + /// returns the empty string, while one directly referenced by + /// `aria-labelledby` is exempt from that check. The exemption is the whole + /// point of the pattern -- `` + /// exists to name something without appearing itself -- and a resolver that + /// skipped it would name nothing on exactly the markup written to use it. + /// + /// The exemption is for the element named by the id, not for its subtree: + /// `name_text` goes on skipping hidden elements inside it, so the half of a + /// responsive label that is not shown stays out of the name. An element + /// nested inside a `display: none` referenced element is not hidden by that + /// rule, because its own computed display is not `none`, which is the answer + /// a browser gives for the same markup. + /// + /// # Termination + /// + /// A reference resolves through `name_text`, which reads text, and never + /// through `semantic_name`, which would consult `aria-labelledby` again. So + /// `

Fleet

` is named "Fleet" rather than + /// recursing, and no cycle between elements can exist to be guarded against. + /// ARIA reaches the same answer by forbidding the second traversal. + fn labelled_by( + &self, + document: &blitz_dom::BaseDocument, + element: &blitz_dom::ElementData, + ) -> Option { + let reference = element_attr(element, "aria-labelledby")?; + let mut name = String::new(); + for token in reference.split_ascii_whitespace() { + // An id that matches nothing contributes nothing, rather than + // abandoning the whole name. A list of ids is written by hand and + // one of them going stale is the common way it breaks; the names + // that do still resolve are worth more than an empty string. + let Some(node) = self + .by_dom_id + .get(token) + .and_then(|id| document.get_node(*id)) + else { + continue; + }; + if !name.is_empty() { + name.push(' '); + } + name.push_str(&name_text(node, document)); } + // Nothing resolved, or everything that did was blank. That is the + // absence of a name rather than a name, and returning it would make + // every rule below this one unreachable for the element. + (!name.trim().is_empty()).then_some(name) } /// The label text for a control, by association or by containment. @@ -669,6 +816,20 @@ fn name_text(node: &blitz_dom::Node, document: &blitz_dom::BaseDocument) -> Stri let Some(child) = document.get_node(*child) else { continue; }; + // What is not rendered is not part of the name. + // + // A responsive control writes both labels and shows one: + // `sm:hidden` on the short one, `hidden sm:inline` on the long one. + // Folding both together produced "Book Book a diagnostic", a name + // no viewer at any width can see and no check can be written + // against. `visibility: hidden` and `aria-hidden` are excluded for + // the same reason, which is the rule accname states directly. + // + // Elements only. A text node carries no display of its own, so it + // is present exactly when the element holding it is. + if child.element_data().is_some() && !node_is_individually_visible(child) { + continue; + } // A boundary either side, so a block between two others is // separated from both. `normalize_name` collapses the runs. let separate = !is_inline(child); @@ -686,6 +847,65 @@ fn name_text(node: &blitz_dom::Node, document: &blitz_dom::BaseDocument) -> Stri out } +/// Whether a role takes its accessible name from its own subtree when the +/// author wrote no explicit one. +/// +/// ARIA's *nameFrom: author, contents*, and nothing else. The list is closed on +/// purpose: a role that is not on it is named only by what the author declared, +/// because a container's text content is its whole subtree and naming those +/// would give every wrapper on a page a name made of the page. +/// +/// `alert` and `status` are here because they are the roles an application uses +/// to say something happened -- a refusal, a saved confirmation -- and what they +/// say is their content. Without them a live region arrives anonymous, so "the +/// reason is shown" is not a question a suite can ask, and every validation +/// outcome has to be approximated by something else that moved. +/// +/// `menuitem`, `tab` and `treeitem` are the menu, tab and tree equivalents of +/// `option`. Leaving them out made every dropdown item in the fleet anonymous: +/// a `` came back with an empty +/// name, so nothing was announced and no check could name the option it meant +/// to press. +/// +/// The table roles are here because ARIA gives all five of them +/// *nameFrom: contents*, and their absence is why whole tables of crate names, +/// versions and column types were unreadable: the cells were in the tree and +/// every one of them was anonymous, which reads from outside as a table that is +/// not in the tree at all. A row's name being the run of its cells is not an +/// accident of that rule, it is the rule: it is what a screen reader announces +/// when the caret enters the row. +/// +/// `semantic_role` returns a `role` attribute verbatim, so an author who writes +/// one of these opts into the naming this list provides. +#[cfg(all(feature = "agent-control", unix))] +pub(crate) fn names_from_contents(role: &str) -> bool { + matches!( + role, + "button" + | "link" + | "heading" + | "option" + | "alert" + | "status" + // The same class as `alert` and `status`: a tooltip exists to say + // one thing, and what it says is its content. Anonymous, it is a + // node reporting that some explanation is on screen without + // reporting the explanation. + | "tooltip" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "tab" + | "treeitem" + | "cell" + | "gridcell" + | "columnheader" + | "rowheader" + | "row" + ) +} + +#[cfg(all(feature = "agent-control", unix))] pub(crate) fn semantic_name( element: &blitz_dom::ElementData, node: &blitz_dom::Node, @@ -694,8 +914,17 @@ pub(crate) fn semantic_name( id: blitz_dom::NodeId, labels: &LabelIndex, ) -> String { - let name = element_attr(element, "aria-label") - .map(std::borrow::Cow::Borrowed) + // The elements an author pointed at, which outrank everything below. + // + // ARIA's order: `aria-labelledby` first, then `aria-label`, then the host + // language's own labelling, then contents. It is first because it is the + // most deliberate thing an author can write. Naming one element by the + // visible text of another says the two belong together, and it is the only + // rule here that can name something from outside itself. + let name = labels + .labelled_by(document, element) + .map(std::borrow::Cow::Owned) + .or_else(|| element_attr(element, "aria-label").map(std::borrow::Cow::Borrowed)) // The label a form control was given. After `aria-label`, which is the // author overriding the visible text on purpose, and before `title`, // which is a tooltip rather than a name. @@ -706,47 +935,34 @@ pub(crate) fn semantic_name( }) .or_else(|| element_attr(element, "alt").map(std::borrow::Cow::Borrowed)) .or_else(|| element_attr(element, "title").map(std::borrow::Cow::Borrowed)) + // An option's `label`, which HTML gives precedence over the option's + // own text: `` announces the + // label. + .or_else(|| { + (role == "option") + .then(|| element_attr(element, "label")) + .flatten() + .map(std::borrow::Cow::Borrowed) + }) // Named by their own content. // - // `alert` and `status` are here because they are the roles an - // application uses to say something happened -- a refusal, a saved - // confirmation -- and what they say is their content. Without them a - // live region arrives anonymous, so "the reason is shown" is not a - // question that can be asked, and every validation outcome in a suite - // has to be approximated by something else that moved. - // - // Deliberately not `generic`. A wrapper's text content is its entire - // subtree, so naming those would give every container on the page a - // name made of the whole page. + // Empty contents are not a name, and stopping here on an empty string + // is how the fallbacks below became unreachable for the roles on this + // list. .or_else(|| { - matches!( - role, - "button" - | "link" - | "heading" - | "option" - | "alert" - | "status" - // The menu, tab and tree equivalents of `option`. ARIA names all - // of these from their own content, and leaving them out - // made every dropdown item in the fleet anonymous: a - // `` came - // back with an empty name, so a screen reader announced - // nothing and no check could name the option it meant to - // press. `semantic_role` returns the `role` attribute - // verbatim, so an author who writes one of these opts out - // of the native naming this list is meant to provide. - // - // Still deliberately absent: `cell` and `row`. Their - // content is a whole subtree, which is the same objection - // the comment above raises against `generic`. - | "menuitem" - | "menuitemcheckbox" - | "menuitemradio" - | "tab" - | "treeitem" - ) - .then(|| std::borrow::Cow::Owned(name_text(node, document))) + names_from_contents(role) + .then(|| name_text(node, document)) + .filter(|text| !text.trim().is_empty()) + .map(std::borrow::Cow::Owned) + }) + // What is left of an option that carries no text at all: a + // `` entry is written `