dommy 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Computes an element's WAI-ARIA *accessible name* (the "accname"
|
|
6
|
+
# algorithm) — what WPT's `test_driver.get_computed_label` returns and
|
|
7
|
+
# Testing Library's name option matches. A focused implementation covering
|
|
8
|
+
# the common steps: aria-labelledby, aria-label, native host-language
|
|
9
|
+
# labels (<label>, alt), name-from-content for the roles that allow it, and
|
|
10
|
+
# the title fallback. Name-from-content folds in CSS `::before` / `::after`
|
|
11
|
+
# generated content (the accname algorithm requires it) — but only the text
|
|
12
|
+
# components it can resolve without rendering: quoted strings and `attr()`.
|
|
13
|
+
# `counter()` / `counters()`, bare `url()` images, and other layout-derived
|
|
14
|
+
# content contribute nothing. Embedded control values are out of scope.
|
|
15
|
+
module AccessibleName
|
|
16
|
+
# Roles whose accessible name may come from descendant content. (term /
|
|
17
|
+
# definition are nameFrom:author per ARIA, so they are NOT here.)
|
|
18
|
+
NAME_FROM_CONTENT = %w[
|
|
19
|
+
button cell checkbox columnheader comment gridcell heading
|
|
20
|
+
link menuitem menuitemcheckbox menuitemradio option radio row rowheader
|
|
21
|
+
sectionhead suggestion switch tab tooltip treeitem
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Form controls whose name can come from an associated <label>.
|
|
25
|
+
LABELABLE = %w[button input meter output progress select textarea].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# The accessible name string ("" when none). ASCII whitespace runs are
|
|
30
|
+
# collapsed and the result trimmed, matching how browsers / Playwright
|
|
31
|
+
# flatten an accessible name.
|
|
32
|
+
def compute(element)
|
|
33
|
+
squish(name_of(element, [], referenced: false, allow_content: false))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Collapse ASCII whitespace runs to single spaces and trim.
|
|
37
|
+
def squish(text)
|
|
38
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `referenced`: this node was reached through aria-labelledby, so it must
|
|
42
|
+
# not start a fresh labelledby traversal (prevents loops, and lets a
|
|
43
|
+
# self-reference fall through to its own aria-label/content).
|
|
44
|
+
# `allow_content`: name-from-content is permitted regardless of role (true
|
|
45
|
+
# for referenced and recursed-into nodes); at the top it is role-gated.
|
|
46
|
+
def name_of(node, visited, referenced:, allow_content:)
|
|
47
|
+
return "" unless node.respond_to?(:__dommy_backend_node__)
|
|
48
|
+
|
|
49
|
+
# 1. aria-labelledby (not when already inside a labelledby traversal).
|
|
50
|
+
unless referenced
|
|
51
|
+
labelled = labelledby_name(node, visited)
|
|
52
|
+
return labelled unless labelled.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 2. aria-label.
|
|
56
|
+
aria_label = node.get_attribute("aria-label").to_s
|
|
57
|
+
return aria_label unless aria_label.strip.empty?
|
|
58
|
+
|
|
59
|
+
# 3. Native host-language labeling.
|
|
60
|
+
native = native_name(node, visited)
|
|
61
|
+
return native unless native.nil?
|
|
62
|
+
|
|
63
|
+
# 4. Name from content (role-permitting, or when recursing). Guard
|
|
64
|
+
# against content cycles with the visited set.
|
|
65
|
+
key = node.__dommy_backend_node__
|
|
66
|
+
if (allow_content || NAME_FROM_CONTENT.include?(node.computed_role)) && visited.none? { |v| v.equal?(key) }
|
|
67
|
+
content = content_name(node, visited + [key])
|
|
68
|
+
# Preserve whitespace-only content: a space deep in the subtree is the
|
|
69
|
+
# separator between sibling text runs ("button" + " " + "label").
|
|
70
|
+
return content unless content.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# 5. Tooltip (title) fallback.
|
|
74
|
+
title = node.get_attribute("title").to_s
|
|
75
|
+
return title unless title.empty?
|
|
76
|
+
|
|
77
|
+
# 6. Placeholder — the lowest-priority name source (below the title).
|
|
78
|
+
placeholder = placeholder_fallback(node)
|
|
79
|
+
return placeholder if placeholder
|
|
80
|
+
|
|
81
|
+
""
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# aria-labelledby: join each referenced element's name. Returns nil when
|
|
85
|
+
# the attribute is absent/empty (so the caller falls through).
|
|
86
|
+
def labelledby_name(node, visited)
|
|
87
|
+
referenced_names(node, "aria-labelledby", visited)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Join the accessible names of the elements an IDREF-list attribute
|
|
91
|
+
# (aria-labelledby / aria-describedby) points at. Returns nil when the
|
|
92
|
+
# attribute is absent/empty or resolves to nothing, so the caller falls
|
|
93
|
+
# through. Each referenced node is named with `referenced: true` (it does
|
|
94
|
+
# not restart a labelledby traversal) and `allow_content: true`.
|
|
95
|
+
def referenced_names(node, attribute, visited = [])
|
|
96
|
+
ids = node.get_attribute(attribute).to_s.split(/\s+/).reject(&:empty?)
|
|
97
|
+
return nil if ids.empty?
|
|
98
|
+
|
|
99
|
+
doc = node.respond_to?(:document) ? node.document : nil
|
|
100
|
+
return nil unless doc
|
|
101
|
+
|
|
102
|
+
parts = ids.map do |id|
|
|
103
|
+
ref = doc.get_element_by_id(id)
|
|
104
|
+
ref ? name_of(ref, visited, referenced: true, allow_content: true) : ""
|
|
105
|
+
end
|
|
106
|
+
joined = parts.join(" ").strip
|
|
107
|
+
joined.empty? ? nil : joined
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# <label>/alt host-language names. Returns nil when not applicable.
|
|
111
|
+
def native_name(node, visited)
|
|
112
|
+
tag = node.tag_name.to_s.downcase
|
|
113
|
+
case tag
|
|
114
|
+
when "img", "area"
|
|
115
|
+
alt = node.get_attribute("alt")
|
|
116
|
+
alt.nil? ? nil : alt
|
|
117
|
+
when "input"
|
|
118
|
+
input_native_name(node, visited)
|
|
119
|
+
when "fieldset"
|
|
120
|
+
child_element_name(node, "legend", visited)
|
|
121
|
+
when "figure"
|
|
122
|
+
child_element_name(node, "figcaption", visited)
|
|
123
|
+
when "table"
|
|
124
|
+
child_element_name(node, "caption", visited)
|
|
125
|
+
when *LABELABLE
|
|
126
|
+
label_text(node, visited)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The name of the first child element with the given tag (e.g. a
|
|
131
|
+
# <fieldset>'s <legend>, a <table>'s <caption>). nil when absent.
|
|
132
|
+
def child_element_name(node, tag, visited)
|
|
133
|
+
bn = node.__dommy_backend_node__
|
|
134
|
+
return nil unless bn.respond_to?(:children)
|
|
135
|
+
|
|
136
|
+
child = bn.children.find { |c| c.respond_to?(:name) && c.name.to_s.casecmp?(tag) }
|
|
137
|
+
return nil unless child
|
|
138
|
+
|
|
139
|
+
name_of(node.document.wrap_node(child), visited, referenced: false, allow_content: true)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Input types for which the placeholder contributes the accessible name
|
|
143
|
+
# (the text-like inputs); type=number / range / date / … do not.
|
|
144
|
+
PLACEHOLDER_TYPES = %w[text search tel url email password].freeze
|
|
145
|
+
|
|
146
|
+
def input_native_name(node, visited)
|
|
147
|
+
type = node.get_attribute("type").to_s.downcase
|
|
148
|
+
return node.get_attribute("alt") || node.get_attribute("value").to_s if type == "image"
|
|
149
|
+
return node.get_attribute("value").to_s if %w[button submit reset].include?(type)
|
|
150
|
+
|
|
151
|
+
label_text(node, visited)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The placeholder names a text-like input / textarea, but only as the
|
|
155
|
+
# lowest-priority source (below the title).
|
|
156
|
+
def placeholder_fallback(node)
|
|
157
|
+
tag = node.tag_name.to_s.downcase
|
|
158
|
+
return placeholder_name(node) if tag == "textarea"
|
|
159
|
+
return nil unless tag == "input"
|
|
160
|
+
|
|
161
|
+
type = node.get_attribute("type").to_s.downcase
|
|
162
|
+
(type.empty? || PLACEHOLDER_TYPES.include?(type)) ? placeholder_name(node) : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def placeholder_name(node)
|
|
166
|
+
placeholder = node.get_attribute("placeholder").to_s
|
|
167
|
+
placeholder.empty? ? nil : placeholder
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The concatenated text of the <label>s associated with a control:
|
|
171
|
+
# label[for=id] anywhere, plus an ancestor <label>. nil → no labels.
|
|
172
|
+
def label_text(node, visited)
|
|
173
|
+
labels = associated_labels(node)
|
|
174
|
+
return nil if labels.empty?
|
|
175
|
+
|
|
176
|
+
text = labels.map { |l| name_of(l, visited, referenced: false, allow_content: true) }.join(" ").strip
|
|
177
|
+
text.empty? ? nil : text
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def associated_labels(node)
|
|
181
|
+
labels = []
|
|
182
|
+
id = node.get_attribute("id").to_s
|
|
183
|
+
unless id.empty?
|
|
184
|
+
doc = node.document
|
|
185
|
+
labels.concat(doc.query_selector_all("label[for='#{id}']").to_a) if doc
|
|
186
|
+
end
|
|
187
|
+
ancestor = closest_label(node)
|
|
188
|
+
labels << ancestor if ancestor && !labels.include?(ancestor)
|
|
189
|
+
labels
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def closest_label(node)
|
|
193
|
+
bn = node.__dommy_backend_node__&.parent
|
|
194
|
+
while bn.respond_to?(:name)
|
|
195
|
+
return node.document.wrap_node(bn) if bn.name.to_s.casecmp?("label")
|
|
196
|
+
|
|
197
|
+
bn = bn.parent
|
|
198
|
+
end
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Concatenate child text nodes and the names of element children, with the
|
|
203
|
+
# `::before` content prepended and `::after` content appended (accname
|
|
204
|
+
# name-from-content folds in generated content).
|
|
205
|
+
def content_name(node, visited)
|
|
206
|
+
bn = node.__dommy_backend_node__
|
|
207
|
+
return "" unless bn.respond_to?(:children)
|
|
208
|
+
|
|
209
|
+
children = bn.children.map do |child|
|
|
210
|
+
if child.respond_to?(:text?) && child.text?
|
|
211
|
+
child.text.to_s
|
|
212
|
+
elsif child.respond_to?(:element?) && child.element?
|
|
213
|
+
wrapped = node.document.wrap_node(child)
|
|
214
|
+
next "" unless wrapped
|
|
215
|
+
|
|
216
|
+
name = name_of(wrapped, visited, referenced: false, allow_content: true)
|
|
217
|
+
# Concatenate contributions directly (inline content glues:
|
|
218
|
+
# "button" + "" + "label" -> "buttonlabel"); a block-level box is
|
|
219
|
+
# padded with spaces so sibling cells / blocks separate
|
|
220
|
+
# ("Profile" + "A" -> "Profile A"). compute collapses the runs.
|
|
221
|
+
block_level?(wrapped) ? " #{name} " : name
|
|
222
|
+
else
|
|
223
|
+
""
|
|
224
|
+
end
|
|
225
|
+
end.join
|
|
226
|
+
|
|
227
|
+
pseudo_content(node, "::before") + children + pseudo_content(node, "::after")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Elements that generate a block-level box by the UA stylesheet — used as
|
|
231
|
+
# the fallback when no CSS layer is available to compute `display`.
|
|
232
|
+
BLOCK_TAGS = %w[
|
|
233
|
+
address article aside blockquote caption dd details div dl dt fieldset
|
|
234
|
+
figcaption figure footer form h1 h2 h3 h4 h5 h6 header hr legend li main
|
|
235
|
+
menu nav ol p pre section summary table tbody td tfoot th thead tr ul
|
|
236
|
+
].freeze
|
|
237
|
+
|
|
238
|
+
# Whether an element generates a block-level box, so its text is separated
|
|
239
|
+
# from siblings by whitespace in name-from-content (inline boxes glue). The
|
|
240
|
+
# computed `display` decides when CSS is available (honoring author CSS);
|
|
241
|
+
# otherwise the UA-default block-tag set is used so table cells / list
|
|
242
|
+
# items still separate.
|
|
243
|
+
def block_level?(element)
|
|
244
|
+
display = computed_display(element)
|
|
245
|
+
return BLOCK_TAGS.include?(element.local_name.to_s.downcase) if display.nil?
|
|
246
|
+
|
|
247
|
+
!display.start_with?("inline") && !%w[none contents].include?(display)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def computed_display(element)
|
|
251
|
+
value = Internal::CSS::Cascade.computed_style(element)["display"].to_s
|
|
252
|
+
value.empty? ? nil : value
|
|
253
|
+
rescue StandardError
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The text contribution of a `::before` / `::after` pseudo-element's
|
|
258
|
+
# computed `content`. "" when the CSS layer is unavailable, the pseudo has
|
|
259
|
+
# no generated content (`none` / `normal`), or its content is purely
|
|
260
|
+
# non-text (counter/url/etc.).
|
|
261
|
+
def pseudo_content(node, pseudo)
|
|
262
|
+
return "" unless Internal::CSS::Parser.available?
|
|
263
|
+
|
|
264
|
+
decl = Internal::CSS::ComputedStyleDeclaration.new(node, pseudo_element: pseudo)
|
|
265
|
+
content_text(decl.get_property_value("content"), node)
|
|
266
|
+
rescue StandardError
|
|
267
|
+
""
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Resolve a computed `content` value to its accname text. `counter()` /
|
|
271
|
+
# `counters()` are resolved first (to quoted strings); then it scans for
|
|
272
|
+
# the text-bearing components in order — quoted strings (CSS-unescaped) and
|
|
273
|
+
# `attr(name)` (read off `node`) — and ignores everything else (`none` /
|
|
274
|
+
# `normal`, `url()`, keywords). The image `/ "alt"` syntax is covered for
|
|
275
|
+
# free: its alt string matches the quoted-string branch.
|
|
276
|
+
def content_text(value, node)
|
|
277
|
+
v = value.to_s.strip
|
|
278
|
+
return "" if v.empty? || v == "none" || v == "normal"
|
|
279
|
+
|
|
280
|
+
if v.match?(/\bcounters?\(/i)
|
|
281
|
+
v = Internal::CSS::Counters.substitute(v, Internal::CSS::Cascade.counter_values(node))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
text = +""
|
|
285
|
+
v.scan(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|attr\(\s*([-\w]+)\s*\)/) do
|
|
286
|
+
dq, sq, attr = Regexp.last_match.captures
|
|
287
|
+
text << (attr ? node.get_attribute(attr).to_s : unescape_css_string(dq || sq))
|
|
288
|
+
end
|
|
289
|
+
text
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# CSS string unescaping: `\HEX ` → the code point, `\<char>` → the char.
|
|
293
|
+
def unescape_css_string(str)
|
|
294
|
+
str.gsub(/\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/) do
|
|
295
|
+
hex = Regexp.last_match(1)
|
|
296
|
+
hex ? [hex.to_i(16)].pack("U") : Regexp.last_match(2)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Computes the WAI-ARIA *computed role* of an element — the value WPT's
|
|
6
|
+
# `test_driver.get_computed_role` returns and Testing Library's `getByRole`
|
|
7
|
+
# relies on. An explicit, valid `role` attribute wins; otherwise the implicit
|
|
8
|
+
# role is derived from the HTML element (HTML-AAM), with the common
|
|
9
|
+
# context-sensitive cases (header/footer landmarks, named section/form,
|
|
10
|
+
# input/select by type) handled. Layout- or tree-dependent subtleties
|
|
11
|
+
# (heading levels, th scope/row context) are intentionally simplified.
|
|
12
|
+
module AriaRole
|
|
13
|
+
# Abstract roles can never be a computed role; an explicit one is ignored.
|
|
14
|
+
ABSTRACT = %w[
|
|
15
|
+
command composite input landmark range roletype section sectionhead
|
|
16
|
+
select structure widget window
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# Concrete ARIA 1.2 roles accepted as an explicit `role` token.
|
|
20
|
+
CONCRETE = %w[
|
|
21
|
+
alert alertdialog application article banner blockquote button caption
|
|
22
|
+
cell checkbox code columnheader combobox comment complementary
|
|
23
|
+
contentinfo definition deletion dialog directory document emphasis
|
|
24
|
+
feed figure form generic grid gridcell group heading img insertion link
|
|
25
|
+
list listbox listitem log main mark marquee math menu menubar menuitem
|
|
26
|
+
menuitemcheckbox menuitemradio meter navigation none note option
|
|
27
|
+
paragraph presentation progressbar radio radiogroup region row rowgroup
|
|
28
|
+
rowheader scrollbar search searchbox separator slider spinbutton status
|
|
29
|
+
strong subscript suggestion superscript switch tab table tablist tabpanel
|
|
30
|
+
term textbox time timer toolbar tooltip tree treegrid treeitem
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Deprecated role synonyms normalized to their canonical computed role.
|
|
34
|
+
SYNONYMS = { "directory" => "list", "image" => "img", "presentation" => "none" }.freeze
|
|
35
|
+
|
|
36
|
+
# Roles that require an author-provided accessible name; without one the
|
|
37
|
+
# token is skipped (role fallback continues to the next token, else the
|
|
38
|
+
# implicit role). Per WPT get_computed_role; note Chromium's ariaSnapshot
|
|
39
|
+
# differs for an unnamed terminal role="region"/"form" (it keeps the role).
|
|
40
|
+
NAME_REQUIRED = %w[form region].freeze
|
|
41
|
+
|
|
42
|
+
# Sectioning content under which <header>/<footer> are generic rather than
|
|
43
|
+
# banner/contentinfo landmarks.
|
|
44
|
+
SECTIONING = %w[article aside main nav section].freeze
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# The computed role string, or "" when the element maps to no role.
|
|
49
|
+
def compute(element)
|
|
50
|
+
explicit_role(element) || implicit_role(element) || ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The heading level for a heading element: an explicit positive
|
|
54
|
+
# `aria-level`, else the `hN` tag's number, else nil. The role itself
|
|
55
|
+
# stays "heading" (level is a separate ARIA property the a11y tree emits).
|
|
56
|
+
def heading_level(element)
|
|
57
|
+
aria = element.get_attribute("aria-level").to_s
|
|
58
|
+
return aria.to_i if aria.match?(/\A\d+\z/) && aria.to_i.positive?
|
|
59
|
+
|
|
60
|
+
tag = element.local_name.to_s.downcase
|
|
61
|
+
tag.match?(/\Ah[1-6]\z/) ? tag[1].to_i : nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def explicit_role(element)
|
|
65
|
+
raw = element.get_attribute("role").to_s.strip
|
|
66
|
+
return nil if raw.empty?
|
|
67
|
+
|
|
68
|
+
raw.split(/\s+/).each do |token|
|
|
69
|
+
role = SYNONYMS.fetch(token.downcase, token.downcase)
|
|
70
|
+
next unless CONCRETE.include?(role) && !ABSTRACT.include?(role)
|
|
71
|
+
# region / form need an author-supplied name; otherwise fall through to
|
|
72
|
+
# the next token (or the implicit role).
|
|
73
|
+
next if NAME_REQUIRED.include?(role) && !named?(element)
|
|
74
|
+
# A presentational role yields to the implicit role when the element is
|
|
75
|
+
# focusable or carries a global ARIA state/property (conflict
|
|
76
|
+
# resolution); otherwise it stands.
|
|
77
|
+
return nil if role == "none" && presentation_conflict?(element)
|
|
78
|
+
|
|
79
|
+
return role
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def implicit_role(element)
|
|
85
|
+
case element.tag_name.to_s.downcase
|
|
86
|
+
when "a", "area" then element.has_attribute?("href") ? "link" : (element.tag_name.casecmp?("a") ? "generic" : nil)
|
|
87
|
+
when "article" then "article"
|
|
88
|
+
when "aside" then "complementary"
|
|
89
|
+
when "b", "bdi", "bdo", "data", "i", "q", "samp", "small", "span", "u", "div" then "generic"
|
|
90
|
+
when "blockquote" then "blockquote"
|
|
91
|
+
when "button" then "button"
|
|
92
|
+
when "caption" then "caption"
|
|
93
|
+
when "code" then "code"
|
|
94
|
+
when "del" then "deletion"
|
|
95
|
+
when "dialog" then "dialog"
|
|
96
|
+
when "dd" then "definition"
|
|
97
|
+
when "details" then "group"
|
|
98
|
+
when "dfn" then "term"
|
|
99
|
+
when "dt" then "term"
|
|
100
|
+
when "em" then "emphasis"
|
|
101
|
+
when "fieldset" then "group"
|
|
102
|
+
when "figure" then "figure"
|
|
103
|
+
when "footer" then landmark_or_generic(element, "contentinfo")
|
|
104
|
+
when "form" then named?(element) ? "form" : "generic"
|
|
105
|
+
when "h1", "h2", "h3", "h4", "h5", "h6" then "heading"
|
|
106
|
+
when "header" then landmark_or_generic(element, "banner")
|
|
107
|
+
when "hr" then "separator"
|
|
108
|
+
when "img" then img_role(element)
|
|
109
|
+
when "input" then input_role(element)
|
|
110
|
+
when "ins" then "insertion"
|
|
111
|
+
when "li" then "listitem"
|
|
112
|
+
when "main" then "main"
|
|
113
|
+
when "mark" then "mark"
|
|
114
|
+
when "math" then "math"
|
|
115
|
+
when "menu", "ol", "ul" then "list"
|
|
116
|
+
when "meter" then "meter"
|
|
117
|
+
when "nav" then "navigation"
|
|
118
|
+
when "optgroup" then "group"
|
|
119
|
+
when "option" then "option"
|
|
120
|
+
when "output" then "status"
|
|
121
|
+
when "p" then "paragraph"
|
|
122
|
+
when "progress" then "progressbar"
|
|
123
|
+
when "search" then "search"
|
|
124
|
+
when "section" then named?(element) ? "region" : "generic"
|
|
125
|
+
when "select" then select_role(element)
|
|
126
|
+
when "strong" then "strong"
|
|
127
|
+
when "sub" then "subscript"
|
|
128
|
+
when "sup" then "superscript"
|
|
129
|
+
when "table" then "table"
|
|
130
|
+
when "tbody", "tfoot", "thead" then "rowgroup"
|
|
131
|
+
when "td" then "cell"
|
|
132
|
+
when "textarea" then "textbox"
|
|
133
|
+
when "th" then th_role(element)
|
|
134
|
+
when "time" then "time"
|
|
135
|
+
when "tr" then "row"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# --- helpers --------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def img_role(element)
|
|
142
|
+
alt = element.get_attribute("alt")
|
|
143
|
+
# An explicitly empty alt makes the image presentational. Return the
|
|
144
|
+
# canonical "none" (not its "presentation" synonym) so the accessibility
|
|
145
|
+
# tree flattens it like any other presentational node.
|
|
146
|
+
alt == "" ? "none" : "img"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A <th> is a column or row header. An explicit `scope` wins; otherwise the
|
|
150
|
+
# auto algorithm: a header cell that borders a data cell (<td>) within its
|
|
151
|
+
# row heads that row, so it is a row header; a header cell with only header
|
|
152
|
+
# neighbors heads its column.
|
|
153
|
+
def th_role(element)
|
|
154
|
+
case element.get_attribute("scope").to_s.downcase
|
|
155
|
+
when "row", "rowgroup" then "rowheader"
|
|
156
|
+
when "col", "colgroup" then "columnheader"
|
|
157
|
+
else auto_th_role(element)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def auto_th_role(element)
|
|
162
|
+
row = element.respond_to?(:parent_element) ? element.parent_element : nil
|
|
163
|
+
return "columnheader" unless row
|
|
164
|
+
|
|
165
|
+
cells = row.children.select { |cell| %w[td th].include?(cell.local_name.to_s.downcase) }
|
|
166
|
+
index = cells.index { |cell| same_node?(cell, element) }
|
|
167
|
+
return "columnheader" unless index
|
|
168
|
+
|
|
169
|
+
previous = index.zero? ? nil : cells[index - 1]
|
|
170
|
+
neighbors = [previous, cells[index + 1]].compact
|
|
171
|
+
neighbors.any? { |cell| cell.local_name.to_s.casecmp?("td") } ? "rowheader" : "columnheader"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def same_node?(first, second)
|
|
175
|
+
first && second && first.__dommy_backend_node__ == second.__dommy_backend_node__
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
INPUT_ROLES = {
|
|
179
|
+
"button" => "button", "submit" => "button", "reset" => "button",
|
|
180
|
+
"image" => "button", "file" => "button", "checkbox" => "checkbox", "radio" => "radio",
|
|
181
|
+
"range" => "slider", "number" => "spinbutton", "email" => "textbox",
|
|
182
|
+
"tel" => "textbox", "text" => "textbox", "url" => "textbox", "search" => "searchbox",
|
|
183
|
+
# type=password has no ARIA role per HTML-AAM (to avoid exposing the
|
|
184
|
+
# value) but Chromium / Playwright expose it as a textbox. The
|
|
185
|
+
# date/time family and color likewise expose as a textbox.
|
|
186
|
+
"password" => "textbox", "color" => "textbox", "date" => "textbox",
|
|
187
|
+
"datetime-local" => "textbox", "month" => "textbox", "week" => "textbox",
|
|
188
|
+
"time" => "textbox"
|
|
189
|
+
}.freeze
|
|
190
|
+
|
|
191
|
+
def input_role(element)
|
|
192
|
+
type = element.get_attribute("type").to_s.downcase
|
|
193
|
+
return "textbox" if type.empty?
|
|
194
|
+
|
|
195
|
+
# type=search is searchbox only without a suggestions list; the common
|
|
196
|
+
# case has no list, so map to searchbox.
|
|
197
|
+
INPUT_ROLES[type]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def select_role(element)
|
|
201
|
+
multiple = element.has_attribute?("multiple")
|
|
202
|
+
size = element.get_attribute("size").to_s.to_i
|
|
203
|
+
multiple || size > 1 ? "listbox" : "combobox"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# <header>/<footer> are landmarks only when not scoped to sectioning
|
|
207
|
+
# content; otherwise generic.
|
|
208
|
+
def landmark_or_generic(element, landmark)
|
|
209
|
+
node = element.respond_to?(:__dommy_backend_node__) ? element.__dommy_backend_node__&.parent : nil
|
|
210
|
+
while node && node.respond_to?(:name)
|
|
211
|
+
return "generic" if SECTIONING.include?(node.name.to_s.downcase)
|
|
212
|
+
|
|
213
|
+
node = node.parent
|
|
214
|
+
end
|
|
215
|
+
landmark
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def named?(element)
|
|
219
|
+
return true if present?(element.get_attribute("aria-label"))
|
|
220
|
+
return true if present?(element.get_attribute("aria-labelledby"))
|
|
221
|
+
|
|
222
|
+
present?(element.get_attribute("title"))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def present?(value)
|
|
226
|
+
!value.nil? && !value.to_s.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Presentation conflict resolution (simplified): a focusable element or one
|
|
230
|
+
# with a global ARIA attribute keeps its implicit role.
|
|
231
|
+
def presentation_conflict?(element)
|
|
232
|
+
return true if focusable?(element)
|
|
233
|
+
|
|
234
|
+
%w[aria-label aria-labelledby aria-describedby].any? { |a| present?(element.get_attribute(a)) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Whether the element is focusable — a tabindex, or a natively-focusable
|
|
238
|
+
# control (a/area with href, or a non-disabled form control). Native
|
|
239
|
+
# focusability matters: <a href role=presentation> is still a link.
|
|
240
|
+
def focusable?(element)
|
|
241
|
+
return true if present?(element.get_attribute("tabindex"))
|
|
242
|
+
|
|
243
|
+
case element.local_name.to_s.downcase
|
|
244
|
+
when "a", "area" then element.has_attribute?("href")
|
|
245
|
+
when "button", "select", "textarea" then !element.has_attribute?("disabled")
|
|
246
|
+
when "input" then element.get_attribute("type").to_s.downcase != "hidden" && !element.has_attribute?("disabled")
|
|
247
|
+
else false
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Serializes an AccessibilityTree into a Playwright-compatible ARIA snapshot
|
|
6
|
+
# (a YAML-ish indented outline). Each accessible node is a line
|
|
7
|
+
#
|
|
8
|
+
# - <role> "<name>" [flag] [level=N]:
|
|
9
|
+
#
|
|
10
|
+
# with a trailing ":" only when it has children, 2-space indentation per
|
|
11
|
+
# depth, and significant text as `- text: "…"`. The synthetic root is not
|
|
12
|
+
# printed (a document has no top line), matching Playwright.
|
|
13
|
+
module AriaSnapshot
|
|
14
|
+
# The serialized flags, in fixed render order. A boolean flag renders only
|
|
15
|
+
# when true or "mixed"; `level` renders as `[level=N]`. `readonly` and
|
|
16
|
+
# `required` are computed (AriaState) but NOT serialized — Playwright emits
|
|
17
|
+
# neither in its snapshot — so they stay out of this list.
|
|
18
|
+
FLAG_ORDER = %i[checked disabled expanded level pressed selected].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def serialize(root)
|
|
23
|
+
lines = []
|
|
24
|
+
root.children.each { |child| emit(child, 0, lines) }
|
|
25
|
+
return "" if lines.empty?
|
|
26
|
+
|
|
27
|
+
"#{lines.join("\n")}\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def emit(node, depth, lines)
|
|
31
|
+
indent = " " * depth
|
|
32
|
+
if node.text?
|
|
33
|
+
lines << "#{indent}- text: #{quote(node.name)}"
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
header = "#{indent}- #{node.role}"
|
|
38
|
+
header += " #{quote(node.name)}" unless node.name.to_s.empty?
|
|
39
|
+
flags(node.states).each { |flag| header += " #{flag}" }
|
|
40
|
+
header += ":" unless node.children.empty?
|
|
41
|
+
lines << header
|
|
42
|
+
|
|
43
|
+
node.children.each { |child| emit(child, depth + 1, lines) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The bracketed flag tokens for a node's states, in FLAG_ORDER. Booleans
|
|
47
|
+
# render only when true/"mixed"; a "mixed" value renders `[flag=mixed]`.
|
|
48
|
+
def flags(states)
|
|
49
|
+
FLAG_ORDER.filter_map do |key|
|
|
50
|
+
value = states[key]
|
|
51
|
+
if key == :level
|
|
52
|
+
"[level=#{value}]" if value
|
|
53
|
+
elsif value == "mixed"
|
|
54
|
+
"[#{key}=mixed]"
|
|
55
|
+
elsif value == true
|
|
56
|
+
"[#{key}]"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def quote(text) = "\"#{text.to_s.gsub("\\", "\\\\\\\\").gsub("\"", "\\\"")}\""
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|