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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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