dommy 0.16.0 → 0.17.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +1 -0
  3. data/lib/dommy/backend.rb +3 -0
  4. data/lib/dommy/blob.rb +6 -0
  5. data/lib/dommy/browser.rb +12 -4
  6. data/lib/dommy/character_data.rb +1 -13
  7. data/lib/dommy/cookie_store.rb +2 -1
  8. data/lib/dommy/document.rb +67 -30
  9. data/lib/dommy/element.rb +11 -0
  10. data/lib/dommy/element_internals.rb +12 -0
  11. data/lib/dommy/encodings/legacy_multibyte.rb +800 -0
  12. data/lib/dommy/encodings/multibyte_overrides.rb +76 -0
  13. data/lib/dommy/encodings/prescan.rb +234 -0
  14. data/lib/dommy/encodings.rb +121 -57
  15. data/lib/dommy/event.rb +107 -54
  16. data/lib/dommy/event_source.rb +1 -0
  17. data/lib/dommy/fetch.rb +244 -53
  18. data/lib/dommy/file_reader.rb +1 -0
  19. data/lib/dommy/form_data.rb +22 -9
  20. data/lib/dommy/form_entry_list.rb +3 -3
  21. data/lib/dommy/html_canvas_element.rb +85 -12
  22. data/lib/dommy/html_elements/base.rb +1 -0
  23. data/lib/dommy/html_elements/forms/input.rb +3 -0
  24. data/lib/dommy/html_elements/interactive.rb +56 -48
  25. data/lib/dommy/html_elements/metadata.rb +23 -0
  26. data/lib/dommy/interaction/form_submission.rb +24 -42
  27. data/lib/dommy/internal/accessibility_tree.rb +149 -162
  28. data/lib/dommy/internal/accessibility_visibility.rb +16 -17
  29. data/lib/dommy/internal/accessible_name.rb +231 -246
  30. data/lib/dommy/internal/aria_role.rb +182 -195
  31. data/lib/dommy/internal/aria_snapshot.rb +35 -38
  32. data/lib/dommy/internal/aria_state.rb +102 -118
  33. data/lib/dommy/internal/autofill.rb +75 -75
  34. data/lib/dommy/internal/backend_prefilter.rb +127 -130
  35. data/lib/dommy/internal/child_navigable.rb +86 -83
  36. data/lib/dommy/internal/close_watcher_manager.rb +1 -0
  37. data/lib/dommy/internal/css/ancestor_filter.rb +64 -64
  38. data/lib/dommy/internal/css/calc.rb +90 -71
  39. data/lib/dommy/internal/css/cascade.rb +69 -67
  40. data/lib/dommy/internal/css/color.rb +249 -187
  41. data/lib/dommy/internal/css/computed_style_builder.rb +34 -0
  42. data/lib/dommy/internal/css/computed_style_declaration.rb +50 -11
  43. data/lib/dommy/internal/css/counters.rb +67 -67
  44. data/lib/dommy/internal/css/custom_properties.rb +114 -114
  45. data/lib/dommy/internal/css/parser.rb +277 -239
  46. data/lib/dommy/internal/css/property_registry.rb +321 -302
  47. data/lib/dommy/internal/css/renderability.rb +37 -35
  48. data/lib/dommy/internal/css/rule_index.rb +2 -2
  49. data/lib/dommy/internal/css/rule_index_inputs.rb +2 -2
  50. data/lib/dommy/internal/css/supports.rb +115 -113
  51. data/lib/dommy/internal/css/ua_stylesheet.rb +55 -53
  52. data/lib/dommy/internal/css_rule_text.rb +121 -119
  53. data/lib/dommy/internal/css_source.rb +1 -3
  54. data/lib/dommy/internal/css_syntax.rb +157 -92
  55. data/lib/dommy/internal/declarative_shadow_roots.rb +1 -0
  56. data/lib/dommy/internal/directionality.rb +120 -123
  57. data/lib/dommy/internal/document_live_ranges.rb +26 -1
  58. data/lib/dommy/internal/dom_matching.rb +185 -184
  59. data/lib/dommy/internal/element_geometry.rb +8 -1
  60. data/lib/dommy/internal/element_matching.rb +71 -69
  61. data/lib/dommy/internal/element_shadow.rb +1 -13
  62. data/lib/dommy/internal/element_state.rb +255 -265
  63. data/lib/dommy/internal/event_handlers.rb +66 -64
  64. data/lib/dommy/internal/exception_report.rb +88 -86
  65. data/lib/dommy/internal/focusability.rb +205 -205
  66. data/lib/dommy/internal/global_functions.rb +49 -47
  67. data/lib/dommy/internal/idl_reflection.rb +99 -97
  68. data/lib/dommy/internal/js_number.rb +35 -33
  69. data/lib/dommy/internal/light_dismiss.rb +130 -132
  70. data/lib/dommy/internal/literal_lookup.rb +75 -75
  71. data/lib/dommy/internal/mime_type.rb +54 -49
  72. data/lib/dommy/internal/mutation_coordinator.rb +46 -0
  73. data/lib/dommy/internal/mutation_observer_microtask.rb +60 -0
  74. data/lib/dommy/internal/namespaces.rb +51 -49
  75. data/lib/dommy/internal/node_adopter.rb +25 -1
  76. data/lib/dommy/internal/node_equality.rb +61 -59
  77. data/lib/dommy/internal/post_insertion_steps.rb +1 -0
  78. data/lib/dommy/internal/referrer.rb +144 -0
  79. data/lib/dommy/internal/rendered_text/collector.rb +22 -3
  80. data/lib/dommy/internal/rendered_text/computed_box.rb +3 -2
  81. data/lib/dommy/internal/rendered_text/fragment.rb +37 -38
  82. data/lib/dommy/internal/rendered_text/items.rb +7 -5
  83. data/lib/dommy/internal/selector_matcher.rb +361 -359
  84. data/lib/dommy/internal/selector_parser.rb +74 -62
  85. data/lib/dommy/internal/sequential_focus_navigation.rb +150 -151
  86. data/lib/dommy/internal/shadow_root_registry.rb +7 -0
  87. data/lib/dommy/internal/slot_assignment.rb +117 -0
  88. data/lib/dommy/internal/slot_signals.rb +125 -0
  89. data/lib/dommy/internal/unsafe_html.rb +79 -77
  90. data/lib/dommy/internal/url_parser.rb +5 -29
  91. data/lib/dommy/internal/web_idl.rb +158 -153
  92. data/lib/dommy/internal/window_bound.rb +14 -0
  93. data/lib/dommy/internal/window_constructors.rb +3 -0
  94. data/lib/dommy/internal/xml_serialization.rb +298 -296
  95. data/lib/dommy/js/dom_interfaces.rb +13 -1
  96. data/lib/dommy/js/host_bridge.rb +23 -1
  97. data/lib/dommy/js/host_callbacks.rb +7 -0
  98. data/lib/dommy/js/host_runtime.js +625 -61
  99. data/lib/dommy/js/import_map.rb +1 -1
  100. data/lib/dommy/js/observable_runtime.js +295 -128
  101. data/lib/dommy/js/webidl_members.js +1 -1
  102. data/lib/dommy/js/webidl_signatures.js +35 -6
  103. data/lib/dommy/media_query_list.rb +1 -0
  104. data/lib/dommy/message_channel.rb +2 -0
  105. data/lib/dommy/mutation_observer.rb +25 -29
  106. data/lib/dommy/navigator.rb +4 -1
  107. data/lib/dommy/node.rb +4 -0
  108. data/lib/dommy/notification.rb +1 -0
  109. data/lib/dommy/promise.rb +12 -0
  110. data/lib/dommy/range.rb +20 -3
  111. data/lib/dommy/shadow_root.rb +25 -1
  112. data/lib/dommy/streams.rb +18 -0
  113. data/lib/dommy/style_declaration.rb +15 -4
  114. data/lib/dommy/text_codec.rb +7 -5
  115. data/lib/dommy/tree_walker.rb +68 -61
  116. data/lib/dommy/version.rb +1 -1
  117. data/lib/dommy/web_socket.rb +1 -0
  118. data/lib/dommy/window.rb +22 -4
  119. data/lib/dommy/worker.rb +1 -0
  120. data/lib/dommy/xml_http_request.rb +12 -2
  121. data/lib/dommy.rb +17 -2
  122. metadata +11 -3
@@ -56,291 +56,276 @@ module Dommy
56
56
 
57
57
  extend TextFlattening
58
58
 
59
- module_function
59
+ # Input types for which the placeholder contributes the accessible name
60
+ # (the text-like inputs); type=number / range / date / … do not.
61
+ PLACEHOLDER_TYPES = %w[text search tel url email password].freeze
60
62
 
61
- # The accessible name string ("" when none). ASCII whitespace runs are
62
- # collapsed and the result trimmed, matching how browsers / Playwright
63
- # flatten an accessible name.
64
- def compute(element)
65
- squish(name_of(element, Traversal.start, referenced: false, allow_content: false))
66
- end
63
+ # Elements that generate a block-level box by the UA stylesheet — used as
64
+ # the fallback when an element has no computed `display`.
65
+ BLOCK_TAGS = CSS::UAStylesheet::BLOCK_LEVEL_TAGS
67
66
 
68
- # `referenced`: this node was reached through aria-labelledby, so it must
69
- # not start a fresh labelledby traversal (prevents loops, and lets a
70
- # self-reference fall through to its own aria-label/content).
71
- # `allow_content`: name-from-content is permitted regardless of role (true
72
- # for referenced and recursed-into nodes); at the top it is role-gated.
73
- def name_of(node, traversal, referenced:, allow_content:)
74
- return "" unless node.is_a?(Node) && node.__dommy_backend_node__
75
-
76
- # 1. aria-labelledby (not when already inside a labelledby traversal).
77
- unless referenced
78
- labelled = labelledby_name(node, traversal)
79
- return labelled unless labelled.nil?
67
+ class << self
68
+ # The accessible name string ("" when none). ASCII whitespace runs are
69
+ # collapsed and the result trimmed, matching how browsers / Playwright
70
+ # flatten an accessible name.
71
+ def compute(element)
72
+ squish(name_of(element, Traversal.start, referenced: false, allow_content: false))
80
73
  end
81
74
 
82
- # 2. aria-label.
83
- aria_label = node.__internal_attribute_value__("aria-label").to_s
84
- return aria_label unless aria_label.strip.empty?
85
-
86
- # 3. Native host-language labeling.
87
- native = native_name(node, traversal)
88
- return native unless native.nil?
89
-
90
- # 4. Name from content (role-permitting, or when recursing). Guard
91
- # against content cycles with the visited set.
92
- key = node.__dommy_backend_node__
93
- names_from_content = allow_content || NAME_FROM_CONTENT.include?(node.computed_role) ||
94
- NAME_FROM_CONTENT_TAGS.include?(node.local_name.to_s.downcase)
95
- if names_from_content && !traversal.seen?(key)
96
- content = content_name(node, traversal.entering(key))
97
- # Preserve whitespace-only content: a space deep in the subtree is the
98
- # separator between sibling text runs ("button" + " " + "label").
99
- return content unless content.empty?
75
+ # Join the accessible names of the elements an IDREF-list attribute
76
+ # (aria-labelledby / aria-describedby) associates — the ones its
77
+ # reflection gives, explicitly set or found by ID in the element's own
78
+ # tree. Returns nil when there are none, so the caller falls through.
79
+ # Each referenced node is named with `referenced: true` (it does not
80
+ # restart a labelledby traversal) and `allow_content: true`.
81
+ def referenced_names(node, attribute, traversal = Traversal.start)
82
+ refs = node.respond_to?(:__internal_aria_associated_elements__) ? node.__internal_aria_associated_elements__(attribute) : nil
83
+ return nil if refs.nil? || refs.empty?
84
+
85
+ parts = refs.map do |ref|
86
+ name_of(ref, traversal.naming(hidden_root: AccessibilityVisibility.hidden_for_name?(ref)),
87
+ referenced: true, allow_content: true)
88
+ end
89
+ joined = parts.join(" ").strip
90
+ joined.empty? ? nil : joined
100
91
  end
101
92
 
102
- # 5. Tooltip (title) fallback.
103
- title = node.__internal_attribute_value__("title").to_s
104
- return title unless title.empty?
93
+ # Whether an element generates a block-level box, so its text is separated
94
+ # from siblings by whitespace in name-from-content (inline boxes glue). The
95
+ # computed `display` decides (honoring author CSS); without one the
96
+ # UA-default block-tag set is used so table cells / list items still
97
+ # separate.
98
+ def block_level?(element)
99
+ display = computed_display(element)
100
+ return BLOCK_TAGS.include?(element.local_name.to_s.downcase) if display.nil?
105
101
 
106
- # 6. Placeholder — the lowest-priority name source (below the title).
107
- placeholder = placeholder_fallback(node)
108
- return placeholder if placeholder
102
+ !display.start_with?("inline") && !%w[none contents].include?(display)
103
+ end
109
104
 
110
- ""
111
- end
105
+ private
112
106
 
113
- # aria-labelledby: join each referenced element's name. Returns nil when
114
- # the attribute is absent/empty (so the caller falls through).
115
- def labelledby_name(node, traversal)
116
- referenced_names(node, "aria-labelledby", traversal)
117
- end
107
+ # `referenced`: this node was reached through aria-labelledby, so it must
108
+ # not start a fresh labelledby traversal (prevents loops, and lets a
109
+ # self-reference fall through to its own aria-label/content).
110
+ # `allow_content`: name-from-content is permitted regardless of role (true
111
+ # for referenced and recursed-into nodes); at the top it is role-gated.
112
+ def name_of(node, traversal, referenced:, allow_content:)
113
+ return "" unless node.is_a?(Node) && node.__dommy_backend_node__
118
114
 
119
- # Join the accessible names of the elements an IDREF-list attribute
120
- # (aria-labelledby / aria-describedby) associates — the ones its
121
- # reflection gives, explicitly set or found by ID in the element's own
122
- # tree. Returns nil when there are none, so the caller falls through.
123
- # Each referenced node is named with `referenced: true` (it does not
124
- # restart a labelledby traversal) and `allow_content: true`.
125
- def referenced_names(node, attribute, traversal = Traversal.start)
126
- refs = node.respond_to?(:__internal_aria_associated_elements__) ? node.__internal_aria_associated_elements__(attribute) : nil
127
- return nil if refs.nil? || refs.empty?
128
-
129
- parts = refs.map do |ref|
130
- name_of(ref, traversal.naming(hidden_root: AccessibilityVisibility.hidden_for_name?(ref)),
131
- referenced: true, allow_content: true)
132
- end
133
- joined = parts.join(" ").strip
134
- joined.empty? ? nil : joined
135
- end
136
-
137
- # <label>/alt host-language names. Returns nil when not applicable.
138
- def native_name(node, traversal)
139
- tag = node.tag_name.to_s.downcase
140
- case tag
141
- when "img", "area"
142
- alt = node.__internal_attribute_value__("alt")
143
- alt.nil? ? nil : alt
144
- when "input"
145
- input_native_name(node, traversal)
146
- when "fieldset"
147
- child_element_name(node, "legend", traversal)
148
- when "figure"
149
- child_element_name(node, "figcaption", traversal)
150
- when "table"
151
- child_element_name(node, "caption", traversal)
152
- when *LABELABLE
153
- label_text(node, traversal)
154
- end
155
- end
115
+ # 1. aria-labelledby (not when already inside a labelledby traversal).
116
+ unless referenced
117
+ labelled = labelledby_name(node, traversal)
118
+ return labelled unless labelled.nil?
119
+ end
156
120
 
157
- # The name of the first child element with the given tag (e.g. a
158
- # <fieldset>'s <legend>, a <table>'s <caption>). nil when absent.
159
- def child_element_name(node, tag, traversal)
160
- bn = node.__dommy_backend_node__
161
- return nil unless bn
121
+ # 2. aria-label.
122
+ aria_label = node.__internal_aria_value__("aria-label").to_s
123
+ return aria_label unless aria_label.strip.empty?
124
+
125
+ # 3. Native host-language labeling.
126
+ native = native_name(node, traversal)
127
+ return native unless native.nil?
128
+
129
+ # 4. Name from content (role-permitting, or when recursing). Guard
130
+ # against content cycles with the visited set.
131
+ key = node.__dommy_backend_node__
132
+ names_from_content = allow_content || NAME_FROM_CONTENT.include?(node.computed_role) ||
133
+ NAME_FROM_CONTENT_TAGS.include?(node.local_name.to_s.downcase)
134
+ if names_from_content && !traversal.seen?(key)
135
+ content = content_name(node, traversal.entering(key))
136
+ # Preserve whitespace-only content: a space deep in the subtree is the
137
+ # separator between sibling text runs ("button" + " " + "label").
138
+ return content unless content.empty?
139
+ end
162
140
 
163
- child = bn.children.find { |c| c.name.to_s.casecmp?(tag) }
164
- return nil unless child
141
+ # 5. Tooltip (title) fallback.
142
+ title = node.__internal_attribute_value__("title").to_s
143
+ return title unless title.empty?
165
144
 
166
- name_of(node.document.wrap_node(child), traversal, referenced: false, allow_content: true)
167
- end
145
+ # 6. Placeholder — the lowest-priority name source (below the title).
146
+ placeholder = placeholder_fallback(node)
147
+ return placeholder if placeholder
168
148
 
169
- # Input types for which the placeholder contributes the accessible name
170
- # (the text-like inputs); type=number / range / date / … do not.
171
- PLACEHOLDER_TYPES = %w[text search tel url email password].freeze
149
+ ""
150
+ end
172
151
 
173
- def input_native_name(node, traversal)
174
- type = node.__internal_attribute_value__("type").to_s.downcase
175
- return node.__internal_attribute_value__("alt") || node.__internal_attribute_value__("value").to_s if type == "image"
176
- return node.__internal_attribute_value__("value").to_s if %w[button submit reset].include?(type)
152
+ # aria-labelledby: join each referenced element's name. Returns nil when
153
+ # the attribute is absent/empty (so the caller falls through).
154
+ def labelledby_name(node, traversal)
155
+ referenced_names(node, "aria-labelledby", traversal)
156
+ end
177
157
 
178
- label_text(node, traversal)
179
- end
158
+ # <label>/alt host-language names. Returns nil when not applicable.
159
+ def native_name(node, traversal)
160
+ tag = node.tag_name.to_s.downcase
161
+ case tag
162
+ when "img", "area"
163
+ alt = node.__internal_attribute_value__("alt")
164
+ alt.nil? ? nil : alt
165
+ when "input"
166
+ input_native_name(node, traversal)
167
+ when "fieldset"
168
+ child_element_name(node, "legend", traversal)
169
+ when "figure"
170
+ child_element_name(node, "figcaption", traversal)
171
+ when "table"
172
+ child_element_name(node, "caption", traversal)
173
+ when *LABELABLE
174
+ label_text(node, traversal)
175
+ end
176
+ end
180
177
 
181
- # The placeholder names a text-like input / textarea, but only as the
182
- # lowest-priority source (below the title).
183
- def placeholder_fallback(node)
184
- tag = node.tag_name.to_s.downcase
185
- return placeholder_name(node) if tag == "textarea"
186
- return nil unless tag == "input"
178
+ # The name of the first child element with the given tag (e.g. a
179
+ # <fieldset>'s <legend>, a <table>'s <caption>). nil when absent.
180
+ def child_element_name(node, tag, traversal)
181
+ bn = node.__dommy_backend_node__
182
+ return nil unless bn
187
183
 
188
- type = node.__internal_attribute_value__("type").to_s.downcase
189
- (type.empty? || PLACEHOLDER_TYPES.include?(type)) ? placeholder_name(node) : nil
190
- end
184
+ child = bn.children.find { |c| c.name.to_s.casecmp?(tag) }
185
+ return nil unless child
191
186
 
192
- def placeholder_name(node)
193
- placeholder = node.__internal_attribute_value__("placeholder").to_s
194
- placeholder.empty? ? nil : placeholder
195
- end
187
+ name_of(node.document.wrap_node(child), traversal, referenced: false, allow_content: true)
188
+ end
196
189
 
197
- # The concatenated text of the <label>s associated with a control:
198
- # label[for=id] anywhere, plus an ancestor <label>. nil → no labels.
199
- def label_text(node, traversal)
200
- labels = associated_labels(node)
201
- return nil if labels.empty?
202
-
203
- text = labels.map do |label|
204
- name_of(label, traversal.naming(skip: node, hidden_root: AccessibilityVisibility.hidden_for_name?(label)),
205
- referenced: false, allow_content: true)
206
- end.join(" ").strip
207
- text.empty? ? nil : text
208
- end
190
+ def input_native_name(node, traversal)
191
+ type = node.__internal_attribute_value__("type").to_s.downcase
192
+ return node.__internal_attribute_value__("alt") || node.__internal_attribute_value__("value").to_s if type == "image"
193
+ return node.__internal_attribute_value__("value").to_s if %w[button submit reset].include?(type)
209
194
 
210
- # A labelable control answers its own `labels` (HTML's live list, which
211
- # already covers both the `for=` references and a wrapping label, in
212
- # document order). Anything else can still sit inside a label, so the
213
- # ancestor walk remains as the fallback.
214
- def associated_labels(node)
215
- return node.labels.to_a if node.respond_to?(:labels) && node.labels
195
+ label_text(node, traversal)
196
+ end
216
197
 
217
- [closest_label(node)].compact
218
- end
198
+ # The placeholder names a text-like input / textarea, but only as the
199
+ # lowest-priority source (below the title).
200
+ def placeholder_fallback(node)
201
+ tag = node.tag_name.to_s.downcase
202
+ return placeholder_name(node) if tag == "textarea"
203
+ return nil unless tag == "input"
219
204
 
220
- def closest_label(node)
221
- bn = node.__dommy_backend_node__&.parent
222
- while bn
223
- return node.document.wrap_node(bn) if bn.name.to_s.casecmp?("label")
205
+ type = node.__internal_attribute_value__("type").to_s.downcase
206
+ (type.empty? || PLACEHOLDER_TYPES.include?(type)) ? placeholder_name(node) : nil
207
+ end
224
208
 
225
- bn = bn.parent
209
+ def placeholder_name(node)
210
+ placeholder = node.__internal_attribute_value__("placeholder").to_s
211
+ placeholder.empty? ? nil : placeholder
226
212
  end
227
- nil
228
- end
229
213
 
230
- # Concatenate child text nodes and the names of element children, with the
231
- # `::before` content prepended and `::after` content appended (accname
232
- # name-from-content folds in generated content).
233
- def content_name(node, traversal)
234
- bn = node.__dommy_backend_node__
235
- return "" unless bn
236
-
237
- children = bn.children.map do |child|
238
- if child.text?
239
- child.text.to_s
240
- elsif child.element?
241
- wrapped = node.document.wrap_node(child)
242
- next "" unless wrapped
243
- # The control a label names contributes nothing to that label's text
244
- # — `<label>Name <select>…</select></label>` names the select "Name",
245
- # not "Name" plus its own options.
246
- next "" if traversal.skip && NodeIdentity.same_node?(traversal.skip, child)
247
- # A hidden subtree is not part of the name computed from content —
248
- # unless the traversal started at a hidden node, which brings its
249
- # whole subtree along.
250
- next "" if !traversal.hidden_root && AccessibilityVisibility.hidden_for_name?(wrapped)
251
-
252
- name = name_of(wrapped, traversal, referenced: false, allow_content: true)
253
- # Concatenate contributions directly (inline content glues:
254
- # "button" + "" + "label" -> "buttonlabel"); a block-level box is
255
- # padded with spaces so sibling cells / blocks separate
256
- # ("Profile" + "A" -> "Profile A"). compute collapses the runs.
257
- block_level?(wrapped) ? " #{name} " : name
258
- else
259
- ""
260
- end
261
- end.join
214
+ # The concatenated text of the <label>s associated with a control:
215
+ # label[for=id] anywhere, plus an ancestor <label>. nil → no labels.
216
+ def label_text(node, traversal)
217
+ labels = associated_labels(node)
218
+ return nil if labels.empty?
219
+
220
+ text = labels.map do |label|
221
+ name_of(label, traversal.naming(skip: node, hidden_root: AccessibilityVisibility.hidden_for_name?(label)),
222
+ referenced: false, allow_content: true)
223
+ end.join(" ").strip
224
+ text.empty? ? nil : text
225
+ end
262
226
 
263
- pseudo_content(node, "::before") + children + pseudo_content(node, "::after")
264
- end
227
+ # A labelable control answers its own `labels` (HTML's live list, which
228
+ # already covers both the `for=` references and a wrapping label, in
229
+ # document order). Anything else can still sit inside a label, so the
230
+ # ancestor walk remains as the fallback.
231
+ def associated_labels(node)
232
+ return node.labels.to_a if node.respond_to?(:labels) && node.labels
265
233
 
266
- # Elements that generate a block-level box by the UA stylesheet — used as
267
- # the fallback when an element has no computed `display`.
268
- BLOCK_TAGS = CSS::UAStylesheet::BLOCK_LEVEL_TAGS
234
+ [closest_label(node)].compact
235
+ end
269
236
 
270
- # Whether an element generates a block-level box, so its text is separated
271
- # from siblings by whitespace in name-from-content (inline boxes glue). The
272
- # computed `display` decides (honoring author CSS); without one the
273
- # UA-default block-tag set is used so table cells / list items still
274
- # separate.
275
- def block_level?(element)
276
- display = computed_display(element)
277
- return BLOCK_TAGS.include?(element.local_name.to_s.downcase) if display.nil?
237
+ def closest_label(node)
238
+ bn = node.__dommy_backend_node__&.parent
239
+ while bn
240
+ return node.document.wrap_node(bn) if bn.name.to_s.casecmp?("label")
278
241
 
279
- !display.start_with?("inline") && !%w[none contents].include?(display)
280
- end
242
+ bn = bn.parent
243
+ end
244
+ nil
245
+ end
281
246
 
282
- # nil when the element has no computed `display` (it is in no
283
- # document), so block_level? falls back to the UA-default tag set.
284
- def computed_display(element)
285
- value = Internal::CSS::Cascade.computed_style(element)["display"].to_s
286
- value.empty? ? nil : value
287
- end
247
+ # Concatenate child text nodes and the names of element children, with the
248
+ # `::before` content prepended and `::after` content appended (accname
249
+ # name-from-content folds in generated content).
250
+ def content_name(node, traversal)
251
+ bn = node.__dommy_backend_node__
252
+ return "" unless bn
253
+
254
+ children = bn.children.map do |child|
255
+ if child.text?
256
+ child.text.to_s
257
+ elsif child.element?
258
+ wrapped = node.document.wrap_node(child)
259
+ next "" unless wrapped
260
+ # The control a label names contributes nothing to that label's text
261
+ # — `<label>Name <select>…</select></label>` names the select "Name",
262
+ # not "Name" plus its own options.
263
+ next "" if traversal.skip && NodeIdentity.same_node?(traversal.skip, child)
264
+ # A hidden subtree is not part of the name computed from content —
265
+ # unless the traversal started at a hidden node, which brings its
266
+ # whole subtree along.
267
+ next "" if !traversal.hidden_root && AccessibilityVisibility.hidden_for_name?(wrapped)
268
+
269
+ name = name_of(wrapped, traversal, referenced: false, allow_content: true)
270
+ # Concatenate contributions directly (inline content glues:
271
+ # "button" + "" + "label" -> "buttonlabel"); a block-level box is
272
+ # padded with spaces so sibling cells / blocks separate
273
+ # ("Profile" + "A" -> "Profile A"). compute collapses the runs.
274
+ block_level?(wrapped) ? " #{name} " : name
275
+ else
276
+ ""
277
+ end
278
+ end.join
279
+
280
+ pseudo_content(node, "::before") + children + pseudo_content(node, "::after")
281
+ end
288
282
 
289
- # The text contribution of a `::before` / `::after` pseudo-element's
290
- # computed `content`. "" when the pseudo has no generated content
291
- # (`none` / `normal`), or its content is purely non-text
292
- # (counter/url/etc.).
293
- def pseudo_content(node, pseudo)
294
- decl = Internal::CSS::ComputedStyleDeclaration.new(node, pseudo_element: pseudo)
295
- content_text(decl.get_property_value("content"), node)
296
- end
283
+ # nil when the element has no computed `display` (it is in no
284
+ # document), so block_level? falls back to the UA-default tag set.
285
+ def computed_display(element)
286
+ value = Internal::CSS::Cascade.computed_style(element)["display"].to_s
287
+ value.empty? ? nil : value
288
+ end
297
289
 
298
- # Resolve a computed `content` value to its accname text. `counter()` /
299
- # `counters()` are resolved first (to quoted strings); then it scans for
300
- # the text-bearing components in order — quoted strings (CSS-unescaped) and
301
- # `attr(name)` (read off `node`) — and ignores everything else (`none` /
302
- # `normal`, `url()`, keywords). The image `/ "alt"` syntax is covered for
303
- # free: its alt string matches the quoted-string branch.
304
- def content_text(value, node)
305
- v = value.to_s.strip
306
- return "" if v.empty? || v == "none" || v == "normal"
307
-
308
- if v.match?(/\bcounters?\(/i)
309
- v = Internal::CSS::Counters.substitute(v, Internal::CSS::Cascade.counter_values(node))
290
+ # The text contribution of a `::before` / `::after` pseudo-element's
291
+ # computed `content`. "" when the pseudo has no generated content
292
+ # (`none` / `normal`), or its content is purely non-text
293
+ # (counter/url/etc.).
294
+ def pseudo_content(node, pseudo)
295
+ decl = Internal::CSS::ComputedStyleDeclaration.new(node, pseudo_element: pseudo)
296
+ content_text(decl.get_property_value("content"), node)
310
297
  end
311
298
 
312
- text = +""
313
- v.scan(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|attr\(\s*([-\w]+)\s*\)/) do
314
- dq, sq, attr = Regexp.last_match.captures
315
- text << (attr ? node.__internal_attribute_value__(attr).to_s : unescape_css_string(dq || sq))
299
+ # Resolve a computed `content` value to its accname text. `counter()` /
300
+ # `counters()` are resolved first (to quoted strings); then it scans for
301
+ # the text-bearing components in order — quoted strings (CSS-unescaped) and
302
+ # `attr(name)` (read off `node`) — and ignores everything else (`none` /
303
+ # `normal`, `url()`, keywords). The image `/ "alt"` syntax is covered for
304
+ # free: its alt string matches the quoted-string branch.
305
+ def content_text(value, node)
306
+ v = value.to_s.strip
307
+ return "" if v.empty? || v == "none" || v == "normal"
308
+
309
+ if v.match?(/\bcounters?\(/i)
310
+ v = Internal::CSS::Counters.substitute(v, Internal::CSS::Cascade.counter_values(node))
311
+ end
312
+
313
+ text = +""
314
+ v.scan(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|attr\(\s*([-\w]+)\s*\)/) do
315
+ dq, sq, attr = Regexp.last_match.captures
316
+ text << (attr ? node.__internal_attribute_value__(attr).to_s : unescape_css_string(dq || sq))
317
+ end
318
+ text
316
319
  end
317
- text
318
- end
319
320
 
320
- # CSS string unescaping: `\HEX ` → the code point, `\<char>` → the char.
321
- def unescape_css_string(str)
322
- str.gsub(/\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/) do
323
- hex = Regexp.last_match(1)
324
- hex ? [hex.to_i(16)].pack("U") : Regexp.last_match(2)
321
+ # CSS string unescaping: `\HEX ` → the code point, `\<char>` → the char.
322
+ def unescape_css_string(str)
323
+ str.gsub(/\\(?:([0-9a-fA-F]{1,6})[ \t\n]?|(.))/) do
324
+ hex = Regexp.last_match(1)
325
+ hex ? [hex.to_i(16)].pack("U") : Regexp.last_match(2)
326
+ end
325
327
  end
326
328
  end
327
-
328
- # Everything below compute is how it is computed, not API.
329
- private_class_method :name_of
330
- private_class_method :labelledby_name
331
- private_class_method :native_name
332
- private_class_method :child_element_name
333
- private_class_method :input_native_name
334
- private_class_method :placeholder_fallback
335
- private_class_method :placeholder_name
336
- private_class_method :label_text
337
- private_class_method :associated_labels
338
- private_class_method :closest_label
339
- private_class_method :content_name
340
- private_class_method :computed_display
341
- private_class_method :pseudo_content
342
- private_class_method :content_text
343
- private_class_method :unescape_css_string
344
329
  end
345
330
  end
346
331
  end