Module: Dommy::Internal::AccessibilityTree
- Defined in:
- lib/dommy/internal/accessibility_tree.rb
Overview
Builds the accessibility tree of a DOM scope: the tree of accessible objects an assistive technology would see, computed from role, name, description, and ARIA state, with the DOM-to-a11y inclusion rules applied (hidden subtrees removed, presentational and generic nodes flattened, name-from-content text folded into the name). It is the structure an ARIA snapshot serializes and a getByRole query would walk.
Fidelity follows Playwright's aria snapshot rather than the letter of the ARIA spec where they differ (generic containers collapse even when named; name-from-content roles drop descendant text but keep descendant roled nodes). aria-owns reparenting is out of scope for now.
Defined Under Namespace
Classes: Node
Class Method Summary collapse
-
.build(scope) ⇒ Object
Build the tree for
— or an Element).scope(a Document — started at its -
.build_children(element) ⇒ Object
Walk an element's child nodes in document order: significant text becomes a :text node; elements recurse (and may promote).
-
.coalesce_text(nodes) ⇒ Object
Merge runs of adjacent text nodes into one by direct concatenation — inline content glues ("Save" + "Save" -> "SaveSave"); the whitespace that separates block-level content comes from the padding added when a block box is promoted (see nodes_for).
-
.excluded?(element) ⇒ Boolean
An element (and its whole subtree) is excluded from the tree when
aria-hidden="true"or when it is not visually rendered. -
.finalize_text(node) ⇒ Object
Squish each text node's accumulated content and drop the empties, depth-first.
- .format_number(number) ⇒ Object
-
.lone_unscoped_th?(element) ⇒ Boolean
A lone, unscoped
that is the only cell of the only row of its table is not emitted as a header cell — Chromium folds it into the row's accessible name. - .native_widget_value(element) ⇒ Object
The displayed value of a native range / number / color input (the only widgets whose value Chromium puts in the snapshot — ARIA aria-valuenow / progress / meter show nothing).
- .nodes_for(element) ⇒ Object
The accessible nodes an element contributes to its parent: 0 (excluded), 1 (a real node), or many (its promoted children when it is presentational / generic).
- .numeric(value, fallback) ⇒ Object
- .range_default(element) ⇒ Object
- .sole_name_text?(node) ⇒ Boolean
- .squish(text) ⇒ Object
- .text_node(text) ⇒ Object
Class Method Details
.build(scope) ⇒ Object
Build the tree for
— or an Element). Returns a syntheticscope(a Document — started at its:rootNode whose children are the accessible nodes the scope contributes (so a Document has no document line, matching Playwright).52 53 54 55 56 57 58 59 60 61
# File 'lib/dommy/internal/accessibility_tree.rb', line 52 def build(scope) start = scope.respond_to?(:body) ? scope.body : scope root = Node.new(role: :root, element: scope) root.children = start ? nodes_for(start) : [] # Text is carried raw through construction so adjacent runs concatenate # with correct (block vs inline) spacing; collapse it to its final form # once, here. finalize_text(root) root end
.build_children(element) ⇒ Object
Walk an element's child nodes in document order: significant text becomes a :text node; elements recurse (and may promote).
124 125 126 127 128 129 130 131 132 133 134 135 136
# File 'lib/dommy/internal/accessibility_tree.rb', line 124 def build_children(element) out = [] element.child_nodes.each do |child| if child.is_a?(Dommy::Element) out.concat(nodes_for(child)) elsif child.is_a?(Dommy::TextNode) # Keep the text raw (whitespace and all); spacing is resolved when # adjacent runs are coalesced and finally squished. out << text_node(child.text_content.to_s) end end coalesce_text(out) end
.coalesce_text(nodes) ⇒ Object
Merge runs of adjacent text nodes into one by direct concatenation — inline content glues ("Save" + "Save" -> "SaveSave"); the whitespace that separates block-level content comes from the padding added when a block box is promoted (see nodes_for).
142 143 144 145 146 147 148 149 150
# File 'lib/dommy/internal/accessibility_tree.rb', line 142 def coalesce_text(nodes) nodes.each_with_object([]) do |node, out| if node.text? && out.last&.text? out[-1] = text_node("#{out.last.name}#{node.name}") else out << node end end end
.excluded?(element) ⇒ Boolean
An element (and its whole subtree) is excluded from the tree when
aria-hidden="true"or when it is not visually rendered.visible?deliberately ignores aria-hidden, so it is checked here.189 190 191 192 193
# File 'lib/dommy/internal/accessibility_tree.rb', line 189 def excluded?(element) return true if element.get_attribute("aria-hidden").to_s.casecmp?("true") !DomMatching.visible?(element) end
.finalize_text(node) ⇒ Object
Squish each text node's accumulated content and drop the empties, depth-first. Coalescing already merged adjacent runs during build.
65 66 67 68 69 70 71 72 73 74 75 76
# File 'lib/dommy/internal/accessibility_tree.rb', line 65 def finalize_text(node) node.children = node.children.filter_map do |child| if child.text? text = squish(child.name) Node.new(role: :text, name: text) unless text.empty? else finalize_text(child) child end end node end
.format_number(number) ⇒ Object
182 183 184
# File 'lib/dommy/internal/accessibility_tree.rb', line 182 def format_number(number) number == number.to_i ? number.to_i.to_s : number.to_s end
.lone_unscoped_th?(element) ⇒ Boolean
A lone, unscoped
that is the only cell of the only row of its table is not emitted as a header cell — Chromium folds it into the row's accessible name. An explicit scope, a sibling cell, or a second row all make it a real header.199 200 201 202 203 204 205 206 207 208 209 210
# File 'lib/dommy/internal/accessibility_tree.rb', line 199 def lone_unscoped_th?(element) return false unless element.local_name.to_s.casecmp?("th") return false unless element.get_attribute("scope").to_s.empty? table = element.respond_to?(:closest) ? element.closest("table") : nil return false unless table rows = table.query_selector_all("tr").to_a return false unless rows.size == 1 rows.first.query_selector_all("td, th").to_a.size == 1 end
.native_widget_value(element) ⇒ Object
The displayed value of a native range / number / color input (the only widgets whose value Chromium puts in the snapshot — ARIA aria-valuenow / progress / meter show nothing). A range always shows a value (defaulting to the midpoint of its min/max); color defaults to "#000000"; a number shows one only when non-empty.
159 160 161 162 163 164 165 166 167 168
# File 'lib/dommy/internal/accessibility_tree.rb', line 159 def (element) return nil unless element.local_name.to_s.casecmp?("input") value = element.value.to_s case element.get_attribute("type").to_s.downcase when "range" then value.empty? ? range_default(element) : value when "color" then value.empty? ? "#000000" : value when "number" then value.empty? ? nil : value end end
.nodes_for(element) ⇒ Object
The accessible nodes an element contributes to its parent: 0 (excluded), 1 (a real node), or many (its promoted children when it is presentational / generic).
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
# File 'lib/dommy/internal/accessibility_tree.rb', line 81 def nodes_for(element) return [] if excluded?(element) return [] if lone_unscoped_th?(element) role = AriaRole.compute(element) children = build_children(element) # Presentational and generic/roleless containers drop out; their # children are promoted to the parent. A block-level box separates its # text from siblings, so its promoted run is padded with whitespace. if role == "none" || role == "" || role == "generic" return AccessibleName.block_level?(element) ? [text_node(" "), *children, text_node(" ")] : children end node = Node.new( role: role, name: AccessibleName.compute(element), description: AccessibleDescription.compute(element), states: AriaState.compute(element, role), element: element ) # Name-from-content roles fold their descendant text into the name, so # those text nodes are not emitted again; descendant roled nodes stay. node.children = AccessibleName::NAME_FROM_CONTENT.include?(role) ? children.reject(&:text?) : children # When a node's sole content is text that just repeats its (author- # supplied) accessible name, that text is not exposed again # (aria-label="X">X is `banner "X"`, but a text sibling keeps it). node.children = [] if sole_name_text?(node) # A native range / number / color control shows its value as inline text. value = (element) node.children << text_node(value) if value [node] end
.numeric(value, fallback) ⇒ Object
176 177 178 179 180
# File 'lib/dommy/internal/accessibility_tree.rb', line 176 def numeric(value, fallback) Float(value) rescue ArgumentError, TypeError fallback end
.range_default(element) ⇒ Object
170 171 172 173 174
# File 'lib/dommy/internal/accessibility_tree.rb', line 170 def range_default(element) min = numeric(element.get_attribute("min"), 0.0) max = numeric(element.get_attribute("max"), 100.0) format_number(min + ((max - min) / 2.0)) end
.sole_name_text?(node) ⇒ Boolean
115 116 117 118 119 120
# File 'lib/dommy/internal/accessibility_tree.rb', line 115 def sole_name_text?(node) return false unless node.children.size == 1 && !node.name.to_s.empty? child = node.children.first child.text? && squish(child.name) == node.name end
.squish(text) ⇒ Object
212
# File 'lib/dommy/internal/accessibility_tree.rb', line 212 def squish(text) = text.to_s.gsub(/\s+/, " ").strip
- .native_widget_value(element) ⇒ Object