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

Class Method Details

.build(scope) ⇒ Object

Build the tree for scope (a Document — started at its — or an Element). Returns a synthetic :root Node 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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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 native_widget_value(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 = native_widget_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

Returns:

  • (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

.text_node(text) ⇒ Object



152
# File 'lib/dommy/internal/accessibility_tree.rb', line 152

def text_node(text) = Node.new(role: :text, name: text)