dommy 0.7.0 → 0.8.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
data/lib/dommy/element.rb CHANGED
@@ -8,6 +8,7 @@ module Dommy
8
8
  class Fragment
9
9
  include EventTarget
10
10
  include Node
11
+ include Internal::ParentNode
11
12
 
12
13
  attr_reader :document
13
14
 
@@ -28,8 +29,12 @@ module Dommy
28
29
  @__node__.element_children.size
29
30
  end
30
31
 
32
+ # Live, cached childNodes so `fragment.childNodes === fragment.childNodes` and
33
+ # later mutations are reflected (WHATWG live NodeList).
31
34
  def child_nodes
32
- NodeList.new(@__node__.children.map { |n| @document.wrap_node(n) }.compact)
35
+ @live_child_nodes ||= LiveNodeList.new do
36
+ @__node__.children.map { |n| @document.wrap_node(n) }.compact
37
+ end
33
38
  end
34
39
 
35
40
  def first_child
@@ -52,23 +57,18 @@ module Dommy
52
57
  @__node__.text
53
58
  end
54
59
 
55
- def append_child(child)
56
- nodes = detach_dom_nodes(child)
57
- nodes.each { |n| @__node__.add_child(n) }
58
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
59
- child
60
- end
61
-
62
60
  def query_selector(selector)
63
- return nil if selector.nil? || selector.to_s.empty?
61
+ return nil if selector.nil?
62
+ Internal.validate_selector!(selector)
64
63
 
65
- @document.wrap_node(@__node__.at_css(selector.to_s))
64
+ @document.wrap_node(@__node__.at_css(Internal.backend_safe_selector(selector.to_s)))
66
65
  end
67
66
 
68
67
  def query_selector_all(selector)
69
- return NodeList.new if selector.nil? || selector.to_s.empty?
68
+ return NodeList.new if selector.nil?
69
+ Internal.validate_selector!(selector)
70
70
 
71
- NodeList.new(@__node__.css(selector.to_s).map { |n| @document.wrap_node(n) }.compact)
71
+ NodeList.new(@__node__.css(Internal.backend_safe_selector(selector.to_s)).map { |n| @document.wrap_node(n) }.compact)
72
72
  end
73
73
 
74
74
  def get_element_by_id(id)
@@ -81,6 +81,8 @@ module Dommy
81
81
  case key
82
82
  when "nodeType"
83
83
  11
84
+ when "nodeName"
85
+ "#document-fragment"
84
86
  when "children"
85
87
  element_children
86
88
  when "childNodes"
@@ -97,29 +99,69 @@ module Dommy
97
99
  last_element_child
98
100
  when "textContent"
99
101
  @__node__.text
102
+ when "ownerDocument"
103
+ @document
100
104
  end
101
105
  end
102
106
 
103
- # Methods routed through __js_call__ (keep in sync with its when-arms).
104
- JS_METHOD_NAMES = %w[cloneNode querySelector querySelectorAll getElementById appendChild].freeze
105
- def __js_method_names__
106
- JS_METHOD_NAMES
107
- end
108
-
107
+ include Bridge::Methods
108
+ js_methods %w[cloneNode querySelector querySelectorAll getElementById appendChild isEqualNode hasChildNodes
109
+ append prepend replaceChildren removeChild insertBefore replaceChild
110
+ isSameNode getRootNode contains normalize compareDocumentPosition
111
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
112
+ addEventListener removeEventListener dispatchEvent]
109
113
  def __js_call__(method, args)
110
114
  case method
115
+ when "hasChildNodes"
116
+ @__node__.children.any?
117
+ when "compareDocumentPosition"
118
+ compare_document_position(args[0])
119
+ when "lookupNamespaceURI"
120
+ lookup_namespace_uri(args[0])
121
+ when "lookupPrefix"
122
+ lookup_prefix(args[0])
123
+ when "isDefaultNamespace"
124
+ is_default_namespace(args[0])
111
125
  when "cloneNode"
112
126
  deep = args.empty? ? false : !!args[0]
113
127
  deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc)) : @document
114
128
  .wrap_node(Parser.fragment("", owner_doc: @document.nokogiri_doc))
115
129
  when "querySelector"
116
- query_selector(args[0])
130
+ query_selector(Internal.css_query_arg!(args))
117
131
  when "querySelectorAll"
118
- query_selector_all(args[0])
132
+ query_selector_all(Internal.css_query_arg!(args))
119
133
  when "getElementById"
120
134
  get_element_by_id(args[0])
121
135
  when "appendChild"
122
136
  append_child(args[0])
137
+ when "append"
138
+ append(*args)
139
+ when "prepend"
140
+ prepend(*args)
141
+ when "replaceChildren"
142
+ replace_children(*args)
143
+ when "removeChild"
144
+ remove_child(args[0])
145
+ when "insertBefore"
146
+ insert_before(args[0], args[1])
147
+ when "replaceChild"
148
+ replace_child(args[0], args[1])
149
+ when "isEqualNode"
150
+ is_equal_node(args[0])
151
+ when "isSameNode"
152
+ is_same_node(args[0])
153
+ when "getRootNode"
154
+ get_root_node(args[0])
155
+ when "contains"
156
+ contains?(args[0])
157
+ when "normalize"
158
+ normalize
159
+ when "addEventListener"
160
+ add_event_listener(args[0], args[1], args[2])
161
+ when "removeEventListener"
162
+ remove_event_listener(args[0], args[1], args[2])
163
+ when "dispatchEvent"
164
+ dispatch_event(args[0])
123
165
  else
124
166
  nil
125
167
  end
@@ -131,21 +173,50 @@ module Dommy
131
173
  nodes
132
174
  end
133
175
 
134
- private
176
+ # Node mutation on the fragment's children (ParentNode covers append/prepend/
177
+ # replaceChildren; these are the remaining Node methods).
178
+ def remove_child(node)
179
+ bn = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
180
+ raise DOMException::NotFoundError, "node is not a child of this fragment" unless bn && bn.parent == @__node__
135
181
 
136
- def detach_dom_nodes(value)
137
- case value
138
- when String
139
- [@document.create_text_node(value).__dommy_backend_node__]
140
- else
141
- node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
142
- return [] unless node
182
+ bn.unlink
183
+ node
184
+ end
143
185
 
144
- node.unlink if node.parent
145
- [node]
186
+ def insert_before(node, ref)
187
+ nodes = detach_dom_nodes(node)
188
+ ref_bn = ref.respond_to?(:__dommy_backend_node__) ? ref.__dommy_backend_node__ : nil
189
+ if ref_bn && ref_bn.parent == @__node__
190
+ nodes.each { |n| ref_bn.add_previous_sibling(n) }
191
+ else
192
+ nodes.each { |n| @__node__.add_child(n) }
146
193
  end
194
+ node
195
+ end
196
+
197
+ def replace_child(new_child, old_child)
198
+ old_bn = old_child.respond_to?(:__dommy_backend_node__) ? old_child.__dommy_backend_node__ : nil
199
+ raise DOMException::NotFoundError, "node is not a child of this fragment" unless old_bn && old_bn.parent == @__node__
200
+
201
+ detach_dom_nodes(new_child).each { |n| old_bn.add_previous_sibling(n) }
202
+ old_bn.unlink
203
+ old_child
204
+ end
205
+
206
+ def contains?(other)
207
+ return false unless other.respond_to?(:__dommy_backend_node__)
208
+
209
+ on = other.__dommy_backend_node__
210
+ on == @__node__ || on.ancestors.include?(@__node__)
211
+ end
212
+
213
+ def normalize
214
+ @__node__.children.each { |n| n.unlink if n.text? && n.content.empty? }
215
+ nil
147
216
  end
148
217
 
218
+ private
219
+
149
220
  def element_children
150
221
  @__node__.element_children.each_with_object([]) do |node, out|
151
222
  wrapped = @document.wrap_node(node)
@@ -164,9 +235,30 @@ module Dommy
164
235
  # nodeValue / textContent API and `remove` / `cloneNode` semantics.
165
236
  class CharacterDataNode
166
237
  include Node
238
+ include EventTarget
167
239
 
168
240
  def __dommy_backend_node__ = @__node__
169
241
 
242
+ # EventTarget needs a parent for event propagation; a character-data node
243
+ # bubbles to its parent element.
244
+ def __internal_event_parent__
245
+ @__node__.parent && @document.wrap_node(@__node__.parent)
246
+ end
247
+
248
+ # Text.splitText / CharacterData split: break the node at `offset`, keeping
249
+ # [0, offset) here and returning a new sibling node with the remainder.
250
+ def split_text(offset)
251
+ off = offset.to_i
252
+ full = @__node__.content
253
+ raise DOMException::IndexSizeError, "offset #{off} is out of bounds" if off.negative? || off > full.length
254
+
255
+ rest = full[off..] || ""
256
+ write_data(full[0, off])
257
+ new_node = @document.create_text_node(rest)
258
+ @__node__.add_next_sibling(new_node.__dommy_backend_node__) if @__node__.parent
259
+ new_node
260
+ end
261
+
170
262
  def initialize(document, nokogiri_node)
171
263
  @document = document
172
264
  @__node__ = nokogiri_node
@@ -199,19 +291,7 @@ module Dommy
199
291
  end
200
292
 
201
293
  def remove
202
- parent = @__node__.parent
203
- removed = @__node__
204
- @__node__.unlink
205
- # Mirror Element#remove_child: notify with the Nokogiri::Node
206
- # (not the Dommy wrapper) so MutationCoordinator's wrap_node
207
- # cache keys consistently.
208
- if parent
209
- @document.notify_child_list_mutation(
210
- target_node: parent,
211
- added_nodes: [],
212
- removed_nodes: [removed]
213
- )
214
- end
294
+ @document.remove_node_with_notify(@__node__)
215
295
  nil
216
296
  end
217
297
 
@@ -235,22 +315,83 @@ module Dommy
235
315
  __js_set__(key.to_s, value)
236
316
  end
237
317
 
318
+ # WHATWG nodeName for character-data nodes is a per-type constant
319
+ # ("#text" / "#comment" / "#cdata-section"), not the element name.
320
+ def node_name
321
+ case node_type
322
+ when 3 then "#text"
323
+ when 4 then "#cdata-section"
324
+ when 8 then "#comment"
325
+ end
326
+ end
327
+
328
+ # CharacterData length / mutation methods. Offsets and counts are UTF-16 code
329
+ # units per spec; for BMP text (the common case) Ruby char indices match.
330
+ # Each mutating op routes through write_data, which fires the characterData
331
+ # MutationObserver record.
332
+
333
+ def length
334
+ @__node__.content.length
335
+ end
336
+
337
+ def substring_data(offset, count)
338
+ s = @__node__.content
339
+ raise DOMException::IndexSizeError, "offset out of bounds" if offset.to_i.negative? || offset.to_i > s.length
340
+
341
+ s[offset.to_i, [count.to_i, 0].max].to_s
342
+ end
343
+
344
+ def append_data(value)
345
+ write_data(@__node__.content + value.to_s)
346
+ end
347
+
348
+ def insert_data(offset, value)
349
+ replace_data(offset, 0, value)
350
+ end
351
+
352
+ def delete_data(offset, count)
353
+ replace_data(offset, count, "")
354
+ end
355
+
356
+ def replace_data(offset, count, value)
357
+ s = @__node__.content
358
+ o = offset.to_i
359
+ raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > s.length
360
+
361
+ c = [[count.to_i, 0].max, s.length - o].min
362
+ write_data(s[0, o].to_s + value.to_s + s[(o + c)..].to_s)
363
+ end
364
+
238
365
  def __js_get__(key)
239
366
  case key
240
367
  when "nodeType"
241
368
  node_type
369
+ when "nodeName"
370
+ node_name
242
371
  when "textContent"
243
372
  @__node__.content
244
373
  when "data"
245
374
  @__node__.content
246
375
  when "nodeValue"
247
376
  @__node__.content
377
+ when "length"
378
+ length
248
379
  when "parentNode"
249
380
  parent_node
381
+ when "ownerDocument"
382
+ @document
250
383
  when "nextSibling"
251
384
  next_sibling
252
385
  when "previousSibling"
253
386
  previous_sibling
387
+ when "childNodes"
388
+ # CharacterData is a leaf node: childNodes is always an empty (but
389
+ # present and iterable) NodeList, and firstChild/lastChild are null.
390
+ # DOM-walking code (e.g. idiomorph's morphChildren) iterates
391
+ # `node.childNodes` on every node, so a missing one crashes it.
392
+ NodeList.new
393
+ when "firstChild", "lastChild"
394
+ nil
254
395
  end
255
396
  end
256
397
 
@@ -263,14 +404,58 @@ module Dommy
263
404
  nil
264
405
  end
265
406
 
266
- # Methods routed through __js_call__ (keep in sync with its when-arms).
267
- JS_METHOD_NAMES = %w[remove before after replaceWith].freeze
268
- def __js_method_names__
269
- JS_METHOD_NAMES
270
- end
271
-
407
+ include Bridge::Methods
408
+ js_methods %w[remove before after replaceWith isEqualNode hasChildNodes
409
+ appendData insertData deleteData replaceData substringData contains
410
+ isSameNode getRootNode normalize splitText compareDocumentPosition
411
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
412
+ appendChild insertBefore removeChild replaceChild
413
+ addEventListener removeEventListener dispatchEvent]
272
414
  def __js_call__(method, args)
273
415
  case method
416
+ when "hasChildNodes"
417
+ false
418
+ when "contains"
419
+ # A leaf node contains only itself (no descendants).
420
+ args[0].respond_to?(:__dommy_backend_node__) &&
421
+ args[0].__dommy_backend_node__ == @__node__
422
+ when "appendChild", "insertBefore"
423
+ # CharacterData is a leaf — it cannot be a parent.
424
+ raise DOMException::HierarchyRequestError, "this node type does not support children"
425
+ when "removeChild", "replaceChild"
426
+ raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
427
+ when "compareDocumentPosition"
428
+ compare_document_position(args[0])
429
+ when "isSameNode"
430
+ is_same_node(args[0])
431
+ when "getRootNode"
432
+ get_root_node(args[0])
433
+ when "lookupNamespaceURI"
434
+ lookup_namespace_uri(args[0])
435
+ when "lookupPrefix"
436
+ lookup_prefix(args[0])
437
+ when "isDefaultNamespace"
438
+ is_default_namespace(args[0])
439
+ when "normalize"
440
+ nil # a leaf has no child text runs to merge
441
+ when "splitText"
442
+ split_text(args[0])
443
+ when "addEventListener"
444
+ add_event_listener(args[0], args[1], args[2])
445
+ when "removeEventListener"
446
+ remove_event_listener(args[0], args[1], args[2])
447
+ when "dispatchEvent"
448
+ dispatch_event(args[0])
449
+ when "appendData"
450
+ append_data(args[0])
451
+ when "insertData"
452
+ insert_data(args[0], args[1])
453
+ when "deleteData"
454
+ delete_data(args[0], args[1])
455
+ when "replaceData"
456
+ replace_data(args[0], args[1], args[2])
457
+ when "substringData"
458
+ substring_data(args[0], args[1])
274
459
  when "remove"
275
460
  remove
276
461
  when "before"
@@ -279,6 +464,8 @@ module Dommy
279
464
  after(*args)
280
465
  when "replaceWith"
281
466
  replace_with(*args)
467
+ when "isEqualNode"
468
+ is_equal_node(args[0])
282
469
  end
283
470
  end
284
471
 
@@ -375,11 +562,7 @@ module Dommy
375
562
  end
376
563
 
377
564
  # Own __js_call__ methods, on top of CharacterDataNode's.
378
- JS_METHOD_NAMES = %w[cloneNode].freeze
379
- def __js_method_names__
380
- super + JS_METHOD_NAMES
381
- end
382
-
565
+ js_methods %w[cloneNode]
383
566
  def __js_call__(method, args)
384
567
  case method
385
568
  when "cloneNode"
@@ -390,17 +573,21 @@ module Dommy
390
573
  end
391
574
  end
392
575
 
576
+ # CDATASection — a Text subtype (nodeType 4). CharacterData methods and the
577
+ # "#cdata-section" nodeName come from CharacterDataNode via node_type.
578
+ class CDATASectionNode < TextNode
579
+ def node_type
580
+ 4
581
+ end
582
+ end
583
+
393
584
  class CommentNode < CharacterDataNode
394
585
  def node_type
395
586
  8
396
587
  end
397
588
 
398
589
  # Own __js_call__ methods, on top of CharacterDataNode's.
399
- JS_METHOD_NAMES = %w[cloneNode].freeze
400
- def __js_method_names__
401
- super + JS_METHOD_NAMES
402
- end
403
-
590
+ js_methods %w[cloneNode]
404
591
  def __js_call__(method, args)
405
592
  case method
406
593
  when "cloneNode"
@@ -417,8 +604,11 @@ module Dommy
417
604
  class ClassList
418
605
  include Enumerable
419
606
 
420
- def initialize(element)
607
+ # `attribute` is the content attribute this token list reflects ("class" for
608
+ # `classList`, "rel" for `relList`, "sandbox", "sizes", "for", …).
609
+ def initialize(element, attribute = "class")
421
610
  @element = element
611
+ @attribute = attribute
422
612
  end
423
613
 
424
614
  def length
@@ -428,15 +618,18 @@ module Dommy
428
618
  alias size length
429
619
 
430
620
  def item(index)
431
- class_tokens[index.to_i]
621
+ i = index.to_i
622
+ return nil if i.negative?
623
+
624
+ class_tokens[i]
432
625
  end
433
626
 
434
627
  def value
435
- @element.__dommy_backend_node__["class"].to_s
628
+ @element.__dommy_backend_node__[@attribute].to_s
436
629
  end
437
630
 
438
631
  def value=(new_value)
439
- @element.set_attribute("class", new_value.to_s)
632
+ @element.set_attribute(@attribute, new_value.to_s)
440
633
  end
441
634
 
442
635
  # Spec: contains() does NOT validate (no SyntaxError on empty).
@@ -455,14 +648,22 @@ module Dommy
455
648
  end
456
649
 
457
650
  def replace(old_token, new_token)
458
- old_s = validate_token(old_token)
459
- new_s = validate_token(new_token)
651
+ # Spec order: both tokens' empty checks (SyntaxError) precede both
652
+ # whitespace checks (InvalidCharacterError) — so replace(" ", "") is a
653
+ # SyntaxError (the empty newToken), not an InvalidCharacterError.
654
+ old_s = stringify_token(old_token)
655
+ new_s = stringify_token(new_token)
656
+ raise DOMException::SyntaxError, "token is empty" if old_s.empty? || new_s.empty?
657
+ if old_s.match?(/[ \t\n\f\r]/) || new_s.match?(/[ \t\n\f\r]/)
658
+ raise DOMException::InvalidCharacterError, "token contains whitespace"
659
+ end
660
+
460
661
  tokens = class_tokens
461
662
  idx = tokens.index(old_s)
462
663
  return false unless idx
463
664
 
464
665
  tokens[idx] = new_s
465
- @element.set_attribute("class", tokens.uniq.join(" "))
666
+ @element.set_attribute(@attribute, tokens.uniq.join(" "))
466
667
  true
467
668
  end
468
669
 
@@ -489,8 +690,14 @@ module Dommy
489
690
  when "value"
490
691
  value
491
692
  else
492
- if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
493
- item(key.to_i)
693
+ # Indexed getter: `classList[i]` is an undefined-returning indexed
694
+ # property — out-of-range or negative indices yield JS `undefined`
695
+ # (unlike `item(i)`, which returns null). Returning Ruby nil here would
696
+ # marshal as JS null, so use the UNDEFINED sentinel.
697
+ if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
698
+ i = key.to_i
699
+ token = i.negative? ? nil : class_tokens[i]
700
+ token.nil? ? Bridge::UNDEFINED : token
494
701
  end
495
702
  end
496
703
  end
@@ -504,28 +711,30 @@ module Dommy
504
711
  nil
505
712
  end
506
713
 
507
- # Methods routed through __js_call__ (keep in sync with its when-arms).
508
- JS_METHOD_NAMES = %w[add remove contains toggle replace item].freeze
509
- def __js_method_names__
510
- JS_METHOD_NAMES
511
- end
512
-
714
+ include Bridge::Methods
715
+ # NOTE: `supports` is intentionally absent for the class attribute's token
716
+ # list it must throw a TypeError, which `list.supports(...)` (not a function)
717
+ # already does.
718
+ js_methods %w[add remove contains toggle replace item toString]
513
719
  def __js_call__(method, args)
514
720
  case method
515
721
  when "add"
516
722
  update_tokens { |tokens| tokens | normalize_tokens(args) }
517
- nil
723
+ Bridge::UNDEFINED
518
724
  when "remove"
519
725
  update_tokens { |tokens| tokens - normalize_tokens(args) }
520
- nil
726
+ Bridge::UNDEFINED
521
727
  when "contains"
522
- class_tokens.include?(args[0].to_s)
728
+ # contains() does not validate; null coerces to the string "null".
729
+ class_tokens.include?(stringify_token(args[0]))
523
730
  when "toggle"
524
731
  toggle(args[0], args[1])
525
732
  when "replace"
526
733
  replace(args[0], args[1])
527
734
  when "item"
528
735
  item(args[0])
736
+ when "toString"
737
+ value
529
738
  else
530
739
  nil
531
740
  end
@@ -536,52 +745,63 @@ module Dommy
536
745
  def toggle(token, force)
537
746
  name = validate_token(token)
538
747
  present = class_tokens.include?(name)
539
- if force.nil?
540
- desired = !present
541
- else
542
- desired = !!force
543
- end
748
+ force_given = !(force.nil? || force.equal?(Bridge::UNDEFINED))
544
749
 
545
- update_tokens do |tokens|
546
- desired ? (tokens | [name]) : (tokens - [name])
750
+ # Spec: toggle runs the update steps only when it actually adds or removes.
751
+ # With an explicit force that already matches the current state it's a
752
+ # no-op — the attribute is left byte-for-byte untouched (no re-serialize).
753
+ if force_given
754
+ want = !!force
755
+ return want if want == present
756
+
757
+ update_tokens { |tokens| want ? tokens | [name] : tokens - [name] }
758
+ return want
547
759
  end
548
760
 
761
+ desired = !present
762
+ update_tokens { |tokens| desired ? tokens | [name] : tokens - [name] }
549
763
  desired
550
764
  end
551
765
 
766
+ # USVString coercion of a token argument: JS `null` becomes the string
767
+ # "null" (so `add(null)` adds the token "null"), not the empty string.
768
+ def stringify_token(token)
769
+ token.nil? ? "null" : token.to_s
770
+ end
771
+
552
772
  # Spec: any empty-string argument throws SyntaxError; any token
553
773
  # containing ASCII whitespace throws InvalidCharacterError. Applies
554
774
  # to add / remove / replace / toggle.
555
775
  def normalize_tokens(args)
556
- args.map do |t|
557
- s = t.to_s
558
- raise DOMException::SyntaxError, "token is empty" if s.empty?
559
- raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
560
-
561
- s
562
- end
776
+ args.map { |t| validate_token(t) }
563
777
  end
564
778
 
565
779
  def validate_token(token)
566
- s = token.to_s
780
+ s = stringify_token(token)
567
781
  raise DOMException::SyntaxError, "token is empty" if s.empty?
568
782
  raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
569
783
 
570
784
  s
571
785
  end
572
786
 
787
+ # The DOMTokenList token set: the class attribute parsed as an *ordered set*
788
+ # (whitespace-split, duplicates removed preserving first-seen order). length,
789
+ # item, iteration, and contains all operate on this set; `value`/`toString`
790
+ # return the raw attribute. ASCII whitespace per the spec is space/tab/LF/FF/CR.
573
791
  def class_tokens
574
- raw = @element.__dommy_backend_node__["class"].to_s
575
- raw.split(/\s+/).reject(&:empty?)
792
+ raw = @element.__dommy_backend_node__[@attribute].to_s
793
+ raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
576
794
  end
577
795
 
796
+ # DOMTokenList "update steps": serialize the (deduplicated) token set back to
797
+ # the class attribute. add/remove/replace always run this, so duplicates
798
+ # collapse and whitespace normalizes even on a no-op token. The one carve-out
799
+ # (per spec) is an empty set with no existing attribute — don't create one.
578
800
  def update_tokens
579
801
  tokens = yield(class_tokens)
580
- if tokens.empty?
581
- @element.remove_attribute("class") if @element.__dommy_backend_node__.key?("class")
582
- else
583
- @element.set_attribute("class", tokens.join(" "))
584
- end
802
+ return if tokens.empty? && !@element.__dommy_backend_node__.key?(@attribute)
803
+
804
+ @element.set_attribute(@attribute, tokens.join(" "))
585
805
  end
586
806
  end
587
807
 
@@ -602,10 +822,28 @@ module Dommy
602
822
  nil
603
823
  end
604
824
 
825
+ # Named deleter (`delete el.dataset.foo`): removes the data-* attribute.
826
+ def __js_delete__(key)
827
+ @element.remove_attribute(attr_name(key))
828
+ true
829
+ end
830
+
605
831
  def __js_call__(_method, _args)
606
832
  nil
607
833
  end
608
834
 
835
+ # WebIDL "supported property names" for DOMStringMap: each `data-*`
836
+ # attribute's name with the `data-` prefix stripped and `-x` sequences
837
+ # camel-cased (`data-date-of-birth` → `dateOfBirth`, `data-` → ``).
838
+ def __js_named_props__
839
+ Backend.attribute_nodes(@element.__dommy_backend_node__).filter_map do |a|
840
+ name = Backend.attribute_ns_info(a)[:qualified_name]
841
+ next unless name.start_with?("data-")
842
+
843
+ name.sub(/\Adata-/, "").gsub(/-([a-z])/) { ::Regexp.last_match(1).upcase }
844
+ end
845
+ end
846
+
609
847
  private
610
848
 
611
849
  def attr_name(key)
@@ -756,12 +994,8 @@ module Dommy
756
994
  nil
757
995
  end
758
996
 
759
- # Methods routed through __js_call__ (keep in sync with its when-arms).
760
- JS_METHOD_NAMES = %w[setProperty removeProperty getPropertyValue item].freeze
761
- def __js_method_names__
762
- JS_METHOD_NAMES
763
- end
764
-
997
+ include Bridge::Methods
998
+ js_methods %w[setProperty removeProperty getPropertyValue item]
765
999
  def __js_call__(method, args)
766
1000
  case method
767
1001
  when "setProperty"
@@ -828,6 +1062,7 @@ module Dommy
828
1062
  class Element
829
1063
  include EventTarget
830
1064
  include Node
1065
+ include Internal::ParentNode
831
1066
 
832
1067
  attr_reader :document
833
1068
 
@@ -847,6 +1082,11 @@ module Dommy
847
1082
  @live_children = HTMLCollection.new do
848
1083
  @__node__.element_children.map { |n| @document.wrap_node(n) }.compact
849
1084
  end
1085
+ # Live `childNodes` (all node types, not just elements), cached so
1086
+ # `el.childNodes === el.childNodes` holds like the spec's live NodeList.
1087
+ @live_child_nodes = LiveNodeList.new do
1088
+ @__node__.children.map { |n| @document.wrap_node(n) }.compact
1089
+ end
850
1090
  end
851
1091
 
852
1092
  # ----- Public Ruby API (snake_case) -----
@@ -861,19 +1101,61 @@ module Dommy
861
1101
  end
862
1102
 
863
1103
  def text_content=(value)
864
- __js_set__("textContent", value)
1104
+ # `node.content =` removes all existing children and (if value is
1105
+ # non-empty) appends a single text node. Capture before/after to feed
1106
+ # MutationObserver.
1107
+ removed = @__node__.children.to_a
1108
+ @__node__.content = value.to_s
1109
+ added = @__node__.children.to_a
1110
+ notify_child_list(added: added, removed: removed)
865
1111
  end
866
1112
 
867
1113
  def inner_html
868
- __js_get__("innerHTML")
1114
+ if @__node__.name == "template"
1115
+ @document.template_content_inner_html(self)
1116
+ else
1117
+ @__node__.inner_html
1118
+ end
869
1119
  end
870
1120
 
871
1121
  def inner_html=(value)
872
- __js_set__("innerHTML", value)
1122
+ removed = @__node__.children.to_a
1123
+ if @__node__.name == "template"
1124
+ # `<template>` content is invisible to outer selectors in real DOM (it
1125
+ # lives in a separate DocumentFragment exposed via `[:content]`).
1126
+ @document.attach_template_content(self, value.to_s)
1127
+ else
1128
+ @__node__.inner_html = value.to_s
1129
+ @document.migrate_template_descendants(@__node__)
1130
+ end
1131
+ notify_child_list(added: @__node__.children.to_a, removed: removed)
1132
+ end
1133
+
1134
+ HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
1135
+
1136
+ # Record the namespace/prefix/localName an element was created with via
1137
+ # createElementNS, so the getters report them faithfully (Nokogiri can't
1138
+ # always round-trip a foreign-namespace prefix).
1139
+ def __internal_set_namespace__(namespace, prefix, local_name, qualified_name)
1140
+ @__ns_uri = namespace
1141
+ @__ns_prefix = prefix
1142
+ @__ns_local = local_name
1143
+ @__ns_qname = qualified_name
1144
+ nil
873
1145
  end
874
1146
 
1147
+ # tagName is the qualified name, ASCII-upper-cased only for an HTML-namespace
1148
+ # element whose node document is an HTML document. An XHTML element (HTML
1149
+ # namespace, but in an XML document) and any non-HTML-namespace element keep
1150
+ # their case.
875
1151
  def tag_name
876
- @__node__.name.upcase
1152
+ qname = @__ns_qname || @__node__.name
1153
+ html_ns = @__ns_qname ? @__ns_uri == HTML_NAMESPACE : true
1154
+ html_ns && @document.html_document? ? qname.upcase(:ascii) : qname
1155
+ end
1156
+
1157
+ def element_prefix
1158
+ @__ns_prefix
877
1159
  end
878
1160
 
879
1161
  def id
@@ -896,6 +1178,33 @@ module Dommy
896
1178
  @class_list
897
1179
  end
898
1180
 
1181
+ # Element + namespace combinations for which a reflected DOMTokenList IDL
1182
+ # attribute is defined; elsewhere the attribute does not exist (→ undefined).
1183
+ REFLECTED_TOKEN_LIST_HOSTS = {
1184
+ "relList" => {html: %w[a area link], svg: %w[a]},
1185
+ "htmlFor" => {html: %w[output]},
1186
+ "sandbox" => {html: %w[iframe]},
1187
+ "sizes" => {html: %w[link]}
1188
+ }.freeze
1189
+
1190
+ SVG_NAMESPACE = "http://www.w3.org/2000/svg"
1191
+
1192
+ # A reflected DOMTokenList for `prop` backed by content attribute
1193
+ # `attribute`, cached for identity (`el.relList === el.relList`). Returns the
1194
+ # UNDEFINED sentinel (→ JS `undefined`) when the attribute is not defined on
1195
+ # this element in its namespace.
1196
+ def reflected_token_list(prop, attribute)
1197
+ hosts = REFLECTED_TOKEN_LIST_HOSTS[prop]
1198
+ ns = namespace_uri
1199
+ ln = local_name
1200
+ applicable =
1201
+ (ns == HTML_NAMESPACE && hosts[:html].include?(ln)) ||
1202
+ (ns == SVG_NAMESPACE && Array(hosts[:svg]).include?(ln))
1203
+ return Bridge::UNDEFINED unless applicable
1204
+
1205
+ (@reflected_token_lists ||= {})[prop] ||= ClassList.new(self, attribute)
1206
+ end
1207
+
899
1208
  def style
900
1209
  @style
901
1210
  end
@@ -955,7 +1264,7 @@ module Dommy
955
1264
  end
956
1265
 
957
1266
  def has_attributes?
958
- @__node__.attribute_nodes.any?
1267
+ Backend.attribute_nodes(@__node__).any?
959
1268
  end
960
1269
 
961
1270
  def next_sibling
@@ -1013,7 +1322,7 @@ module Dommy
1013
1322
  new_nodes.each { |n| parent.add_child(n) }
1014
1323
  end
1015
1324
 
1016
- @document.notify_child_list_mutation(target_node: parent, added_nodes: new_nodes, removed_nodes: [removed])
1325
+ notify_child_list(added: new_nodes, removed: [removed], target: parent)
1017
1326
  end
1018
1327
 
1019
1328
  # `el.contains(other)` — true if `other` is `el` itself or any
@@ -1073,6 +1382,8 @@ module Dommy
1073
1382
  end
1074
1383
 
1075
1384
  def toggle_attribute(name, force = nil)
1385
+ raise DOMException::InvalidCharacterError, "empty attribute name" if name.to_s.empty?
1386
+
1076
1387
  key = name.to_s.downcase
1077
1388
  present = @__node__.key?(key)
1078
1389
  desired = force.nil? ? !present : !!force
@@ -1086,10 +1397,11 @@ module Dommy
1086
1397
  end
1087
1398
 
1088
1399
  def matches?(selector)
1089
- return false if selector.nil? || selector.to_s.empty?
1400
+ return false if selector.nil?
1401
+ Internal.validate_selector!(selector)
1090
1402
 
1091
1403
  # `:scope` pseudo — match against this element itself.
1092
- sel = selector.to_s.gsub(":scope", "*:nth-last-child(n)")
1404
+ sel = Internal.backend_safe_selector(selector.to_s).gsub(":scope", "*:nth-last-child(n)")
1093
1405
  matches_selector?(@__node__, sel)
1094
1406
  end
1095
1407
 
@@ -1116,6 +1428,10 @@ module Dommy
1116
1428
  end
1117
1429
  end
1118
1430
 
1431
+ def get_elements_by_tag_name_ns(namespace, local_name)
1432
+ HTMLCollection.elements_by_tag_name_ns(@__node__, @document, namespace, local_name)
1433
+ end
1434
+
1119
1435
  # NamedNodeMap of attributes. Lazily allocated and re-used so
1120
1436
  # `el.attributes === el.attributes` and `attr.ownerElement === el`.
1121
1437
  def attributes
@@ -1138,11 +1454,15 @@ module Dommy
1138
1454
 
1139
1455
  # HTML namespace constants — most HTML elements live in xhtml ns.
1140
1456
  def namespace_uri
1141
- ns = @__node__.namespace
1142
- ns ? ns.href : "http://www.w3.org/1999/xhtml"
1457
+ return @__ns_uri if @__ns_qname
1458
+
1459
+ ns = Backend.namespace_of(@__node__)
1460
+ ns ? ns.href : HTML_NAMESPACE
1143
1461
  end
1144
1462
 
1145
1463
  def local_name
1464
+ return @__ns_local if @__ns_qname
1465
+
1146
1466
  @__node__.name.downcase
1147
1467
  end
1148
1468
 
@@ -1300,22 +1620,22 @@ module Dommy
1300
1620
 
1301
1621
  node = detach_for_insert(element)
1302
1622
  @__node__.add_previous_sibling(node)
1303
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1623
+ notify_child_list(added: [node], target: @__node__.parent)
1304
1624
  when "afterbegin"
1305
1625
  node = detach_for_insert(element)
1306
1626
  first = @__node__.children.first
1307
1627
  first ? first.add_previous_sibling(node) : @__node__.add_child(node)
1308
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1628
+ notify_child_list(added: [node])
1309
1629
  when "beforeend"
1310
1630
  node = detach_for_insert(element)
1311
1631
  @__node__.add_child(node)
1312
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1632
+ notify_child_list(added: [node])
1313
1633
  when "afterend"
1314
1634
  return nil unless @__node__.parent
1315
1635
 
1316
1636
  node = detach_for_insert(element)
1317
1637
  @__node__.add_next_sibling(node)
1318
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1638
+ notify_child_list(added: [node], target: @__node__.parent)
1319
1639
  else
1320
1640
  return nil
1321
1641
  end
@@ -1324,36 +1644,56 @@ module Dommy
1324
1644
  end
1325
1645
 
1326
1646
  def insert_adjacent_html(position, html)
1647
+ # Position is ASCII case-insensitive ("beforeBegin" == "beforebegin").
1648
+ pos = position.to_s.downcase
1649
+ unless %w[beforebegin afterbegin beforeend afterend].include?(pos)
1650
+ raise DOMException::SyntaxError, "The value provided ('#{position}') is not one of 'beforeBegin', 'afterBegin', 'beforeEnd', or 'afterEnd'."
1651
+ end
1652
+
1327
1653
  fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
1328
1654
  nodes = fragment.children.to_a
1329
- case position.to_s
1655
+ # `add_previous_sibling` inserts immediately before the anchor, so a forward
1656
+ # walk preserves document order; `add_next_sibling` inserts immediately
1657
+ # after, so afterend walks in reverse to keep order.
1658
+ case pos
1330
1659
  when "beforebegin"
1331
- return nil unless @__node__.parent
1332
-
1333
- nodes.reverse_each { |n| @__node__.add_previous_sibling(n) }
1334
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: nodes, removed_nodes: [])
1660
+ parent = insertion_parent!
1661
+ nodes.each { |n| @__node__.add_previous_sibling(n) }
1662
+ notify_child_list(added: nodes, target: parent)
1335
1663
  when "afterbegin"
1336
1664
  first = @__node__.children.first
1337
1665
  if first
1338
- nodes.reverse_each { |n| first.add_previous_sibling(n) }
1666
+ nodes.each { |n| first.add_previous_sibling(n) }
1339
1667
  else
1340
1668
  nodes.each { |n| @__node__.add_child(n) }
1341
1669
  end
1342
1670
 
1343
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1671
+ notify_child_list(added: nodes)
1344
1672
  when "beforeend"
1345
1673
  nodes.each { |n| @__node__.add_child(n) }
1346
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1674
+ notify_child_list(added: nodes)
1347
1675
  when "afterend"
1348
- return nil unless @__node__.parent
1349
-
1676
+ parent = insertion_parent!
1350
1677
  nodes.reverse_each { |n| @__node__.add_next_sibling(n) }
1351
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: nodes, removed_nodes: [])
1678
+ notify_child_list(added: nodes, target: parent)
1352
1679
  end
1353
1680
 
1354
1681
  nil
1355
1682
  end
1356
1683
 
1684
+ # The parent that a beforebegin/afterend insertion targets. Per the spec, if
1685
+ # the element has no parent, or its parent is the Document, there is nowhere
1686
+ # to insert a sibling — throw NoModificationAllowedError.
1687
+ def insertion_parent!
1688
+ parent = @__node__.parent
1689
+ is_document = parent && ((parent.respond_to?(:document?) && parent.document?) || parent.name == "document")
1690
+ if parent.nil? || is_document
1691
+ raise DOMException::NoModificationAllowedError, "The element has no parent."
1692
+ end
1693
+
1694
+ parent
1695
+ end
1696
+
1357
1697
  def insert_adjacent_text(position, text)
1358
1698
  return nil if text.to_s.empty?
1359
1699
 
@@ -1389,48 +1729,7 @@ module Dommy
1389
1729
  # CONTAINS/CONTAINED_BY bitmask for ancestor/descendant pairs, or
1390
1730
  # PRECEDING/FOLLOWING for siblings (and DISCONNECTED for unrelated
1391
1731
  # nodes).
1392
- def compare_document_position(other)
1393
- return 0 if equal?(other)
1394
- return DOCUMENT_POSITION_DISCONNECTED unless other.respond_to?(:__dommy_backend_node__)
1395
-
1396
- self_node = @__node__
1397
- other_node = other.__dommy_backend_node__
1398
-
1399
- self_ancestors = ancestor_chain(self_node)
1400
- other_ancestors = ancestor_chain(other_node)
1401
-
1402
- common = nil
1403
- self_ancestors.each do |a|
1404
- if other_ancestors.include?(a)
1405
- common = a
1406
- break
1407
- end
1408
- end
1409
-
1410
- unless common
1411
- return DOCUMENT_POSITION_DISCONNECTED | DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC | DOCUMENT_POSITION_PRECEDING
1412
- end
1413
-
1414
- if common == self_node
1415
- return DOCUMENT_POSITION_CONTAINED_BY | DOCUMENT_POSITION_FOLLOWING
1416
- elsif common == other_node
1417
- return DOCUMENT_POSITION_CONTAINS | DOCUMENT_POSITION_PRECEDING
1418
- end
1419
-
1420
- # Sibling-of-some-level case: compare the two branch points
1421
- # under the common ancestor.
1422
- self_branch = branch_under(common, self_ancestors)
1423
- other_branch = branch_under(common, other_ancestors)
1424
- common.children.each do |child|
1425
- if child == self_branch
1426
- return DOCUMENT_POSITION_FOLLOWING
1427
- elsif child == other_branch
1428
- return DOCUMENT_POSITION_PRECEDING
1429
- end
1430
- end
1431
-
1432
- DOCUMENT_POSITION_DISCONNECTED
1433
- end
1732
+ # compareDocumentPosition is provided generically by the Node module.
1434
1733
 
1435
1734
  # `Node.isSameNode(other)` — strict reference identity. The DOM
1436
1735
  # spec deprecates this in favor of `===`, but linkedom-style
@@ -1455,51 +1754,8 @@ module Dommy
1455
1754
  end
1456
1755
  end
1457
1756
 
1458
- private
1459
-
1460
- def ancestor_chain(node)
1461
- chain = [node]
1462
- Internal::NodeTraversal.each_ancestor(node) { |n| chain << n }
1463
- chain
1464
- end
1465
-
1466
- def branch_under(common, chain)
1467
- # Walk back along `chain` to find the entry whose parent is `common`.
1468
- chain.each_with_index do |node, i|
1469
- return node if i.zero? && node == common
1470
- return node if node.respond_to?(:parent) && node.parent == common
1471
- end
1472
-
1473
- nil
1474
- end
1475
-
1476
- def attribute_signature
1477
- @__node__.attribute_nodes.map { |a| [a.name, a.value] }.sort
1478
- end
1479
-
1480
- public
1481
-
1482
1757
  def remove
1483
- __js_call__("remove", [])
1484
- end
1485
-
1486
- # ParentNode mixin methods — append / prepend / replaceChildren
1487
- # take a mix of Node and String args (strings become text nodes).
1488
-
1489
- def append(*args)
1490
- append_nodes(args)
1491
- end
1492
-
1493
- def prepend(*args)
1494
- prepend_nodes(args)
1495
- end
1496
-
1497
- def replace_children(*args)
1498
- removed = @__node__.children.to_a
1499
- removed.each(&:unlink)
1500
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1501
- nodes.each { |n| @__node__.add_child(n) }
1502
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: removed)
1758
+ @document.remove_node_with_notify(@__node__)
1503
1759
  nil
1504
1760
  end
1505
1761
 
@@ -1529,7 +1785,43 @@ module Dommy
1529
1785
  end
1530
1786
 
1531
1787
  def click
1532
- __js_call__("click", [])
1788
+ dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
1789
+ end
1790
+
1791
+ def get_attribute_names
1792
+ Backend.attribute_nodes(@__node__).map(&:name)
1793
+ end
1794
+
1795
+ # No layout engine — geometry getters return zeroed rects.
1796
+ def get_bounding_client_rect
1797
+ DOMRect.new
1798
+ end
1799
+
1800
+ def get_client_rects
1801
+ []
1802
+ end
1803
+
1804
+ def request_fullscreen
1805
+ @document.__internal_set_fullscreen_element__(self)
1806
+ PromiseValue.resolve(@document.default_view, nil)
1807
+ end
1808
+
1809
+ # Popover API — show / hide / toggle fire beforetoggle + toggle events
1810
+ # (no real visual change). Return values mirror the IDL.
1811
+ def show_popover
1812
+ toggle_popover_state(true)
1813
+ nil
1814
+ end
1815
+
1816
+ def hide_popover
1817
+ toggle_popover_state(false)
1818
+ nil
1819
+ end
1820
+
1821
+ def toggle_popover
1822
+ new_state = !@__popover_open__
1823
+ toggle_popover_state(new_state)
1824
+ new_state
1533
1825
  end
1534
1826
 
1535
1827
  # Ruby block-style listener (in addition to the (type, callable,
@@ -1579,8 +1871,26 @@ module Dommy
1579
1871
  get_attribute("popover")
1580
1872
  when "children"
1581
1873
  @live_children
1874
+ when "childNodes"
1875
+ @live_child_nodes
1876
+ when "firstChild"
1877
+ first_child
1878
+ when "lastChild"
1879
+ last_child
1880
+ when "childElementCount"
1881
+ child_element_count
1882
+ when "lastElementChild"
1883
+ last_element_child
1884
+ when "nextSibling"
1885
+ next_sibling
1886
+ when "previousSibling"
1887
+ previous_sibling
1888
+ when "nextElementSibling"
1889
+ next_element_sibling
1890
+ when "previousElementSibling"
1891
+ previous_element_sibling
1582
1892
  when "firstElementChild"
1583
- @document.wrap_node(@__node__.element_children.first)
1893
+ first_element_child
1584
1894
  when "parentElement", "parent"
1585
1895
  wrap_parent(@__node__.parent)
1586
1896
  when "parentNode"
@@ -1591,16 +1901,23 @@ module Dommy
1591
1901
  when "textContent"
1592
1902
  @__node__.text
1593
1903
  when "innerHTML"
1594
- if @__node__.name == "template"
1595
- @document.template_content_inner_html(self)
1596
- else
1597
- @__node__.inner_html
1598
- end
1599
-
1904
+ inner_html
1905
+ when "outerHTML"
1906
+ outer_html
1600
1907
  when "tagName"
1601
- @__node__.name.upcase
1908
+ tag_name
1909
+ when "prefix"
1910
+ element_prefix
1602
1911
  when "classList"
1603
1912
  @class_list
1913
+ when "relList"
1914
+ reflected_token_list("relList", "rel")
1915
+ when "htmlFor"
1916
+ reflected_token_list("htmlFor", "for")
1917
+ when "sandbox"
1918
+ reflected_token_list("sandbox", "sandbox")
1919
+ when "sizes"
1920
+ reflected_token_list("sizes", "sizes")
1604
1921
  when "style"
1605
1922
  @style
1606
1923
  when "dataset"
@@ -1632,11 +1949,13 @@ module Dommy
1632
1949
  when "localName"
1633
1950
  local_name
1634
1951
  when "nodeName"
1635
- @__node__.name.upcase
1952
+ tag_name
1636
1953
  when "slot"
1637
1954
  slot
1638
1955
  when "role"
1639
- role
1956
+ aria_get("role")
1957
+ when "accessKeyLabel"
1958
+ access_key_label
1640
1959
  when "baseURI"
1641
1960
  base_uri
1642
1961
  when "shadowRoot"
@@ -1644,9 +1963,20 @@ module Dommy
1644
1963
  when "ownerDocument"
1645
1964
  @document
1646
1965
  else
1647
- # `el.onXxx` event handler property — returns the registered
1648
- # callback (if any), or nil.
1649
- if key.start_with?("on") && key.length > 2
1966
+ if (elements_attr = aria_elements_attr(key))
1967
+ # Plural ARIA element references (`ariaDescribedByElements` ↔
1968
+ # `aria-describedby`) a list of Elements.
1969
+ aria_elements_get(elements_attr, key)
1970
+ elsif (element_attr = aria_element_attr(key))
1971
+ # ARIA element-reference IDL attribute (`ariaActiveDescendantElement`
1972
+ # ↔ `aria-activedescendant`) — resolves to an Element or null.
1973
+ aria_element_get(element_attr, key)
1974
+ elsif (content_attr = aria_content_attr(key))
1975
+ # ARIA / role reflected IDL attribute (`ariaLabel` ↔ `aria-label`,
1976
+ # `role` ↔ `role`) — a nullable DOMString (null when absent).
1977
+ aria_get(content_attr)
1978
+ elsif key.start_with?("on") && key.length > 2
1979
+ # `el.onXxx` event handler property — the registered callback or nil.
1650
1980
  @on_handlers&.[](event_name_from_on(key))
1651
1981
  end
1652
1982
  end
@@ -1666,6 +1996,143 @@ module Dommy
1666
1996
  raw.to_s
1667
1997
  end
1668
1998
 
1999
+ # `accessKeyLabel` — the assigned access key's platform label. The
2000
+ # `accesskey` content attribute is a set of one-code-point candidates; a
2001
+ # single valid candidate yields a (modifier-prefixed) label, anything else
2002
+ # (empty, or multiple/multi-char tokens) yields the empty string. The exact
2003
+ # modifier varies by platform — tests only assert non-empty vs empty.
2004
+ def access_key_label
2005
+ keys = @__node__["accesskey"].to_s.split(/[ \t\n\f\r]+/).reject(&:empty?)
2006
+ return "" unless keys.length == 1 && keys.first.length == 1
2007
+
2008
+ "Alt+#{keys.first.upcase}"
2009
+ end
2010
+
2011
+ # The content attribute an ARIA element-reference IDL attribute reflects
2012
+ # (`ariaActiveDescendantElement` → "aria-activedescendant",
2013
+ # `ariaErrorMessageElement` → "aria-errormessage"), or nil. The IDL name is
2014
+ # `aria<Xxx>Element`; the content attribute is "aria-" + <Xxx> lowercased.
2015
+ def aria_element_attr(key)
2016
+ return nil unless key.is_a?(String) && key.start_with?("aria") && key.end_with?("Element")
2017
+ return nil unless key.length > 11 && key[4] =~ /[A-Z]/
2018
+
2019
+ "aria-#{key[4...-7].downcase}"
2020
+ end
2021
+
2022
+ # Read an ARIA element reference: an explicitly-set Element wins; otherwise
2023
+ # the content attribute is resolved as an IDREF (the element with that id in
2024
+ # this element's tree), or null.
2025
+ def aria_element_get(content_attr, key)
2026
+ explicit = (@aria_element_refs ||= {})[key]
2027
+ return explicit if explicit
2028
+
2029
+ idref = @__node__[content_attr].to_s
2030
+ return nil if idref.empty?
2031
+
2032
+ aria_find_in_root(idref)
2033
+ end
2034
+
2035
+ # Set an ARIA element reference: null/undefined clears it and removes the
2036
+ # content attribute; an Element stores the explicit reference and sets the
2037
+ # content attribute to the empty string (per the reflection spec).
2038
+ def aria_element_set(content_attr, key, value)
2039
+ refs = (@aria_element_refs ||= {})
2040
+ if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
2041
+ refs.delete(key)
2042
+ remove_attribute(content_attr) if @__node__.key?(content_attr)
2043
+ else
2044
+ # set_attribute clears explicit refs via its aria-* hook, so store the
2045
+ # new reference afterward.
2046
+ set_attribute(content_attr, "")
2047
+ refs[key] = value
2048
+ end
2049
+ nil
2050
+ end
2051
+
2052
+ # The content attribute a plural ARIA element-references IDL attribute
2053
+ # reflects (`ariaDescribedByElements` → "aria-describedby",
2054
+ # `ariaLabelledByElements` → "aria-labelledby"), or nil. The IDL name is
2055
+ # `aria<Xxx>Elements`; the content attribute is "aria-" + <Xxx> lowercased.
2056
+ def aria_elements_attr(key)
2057
+ return nil unless key.is_a?(String) && key.start_with?("aria") && key.end_with?("Elements")
2058
+ return nil unless key.length > 12 && key[4] =~ /[A-Z]/
2059
+
2060
+ "aria-#{key[4...-8].downcase}"
2061
+ end
2062
+
2063
+ # Read a plural ARIA element references value (a list of Elements): the
2064
+ # explicitly-set array wins; otherwise the content attribute is split as a
2065
+ # space-separated IDREF list and each resolved (missing ids dropped).
2066
+ def aria_elements_get(content_attr, key)
2067
+ explicit = (@aria_elements_refs ||= {})[key]
2068
+ return explicit.dup if explicit
2069
+
2070
+ @__node__[content_attr].to_s.split(/[ \t\n\f\r]+/).reject(&:empty?).filter_map do |id|
2071
+ aria_find_in_root(id)
2072
+ end
2073
+ end
2074
+
2075
+ # Resolve an ARIA IDREF within this element's tree ROOT (its topmost
2076
+ # ancestor) rather than the document — so references keep working when the
2077
+ # subtree is disconnected from the document.
2078
+ def aria_find_in_root(id)
2079
+ root = @__node__
2080
+ root = root.parent while root.parent && !root.parent.is_a?(Backend.document_class)
2081
+ node = ([root] + root.css("*").to_a).find { |n| n["id"].to_s == id }
2082
+ node && @document.wrap_node(node)
2083
+ end
2084
+
2085
+ # Set a plural ARIA element references value: null/undefined clears it and
2086
+ # removes the content attribute; an array of Elements is stored and the
2087
+ # content attribute is set to the empty string.
2088
+ def aria_elements_set(content_attr, key, value)
2089
+ refs = (@aria_elements_refs ||= {})
2090
+ if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
2091
+ refs.delete(key)
2092
+ remove_attribute(content_attr) if @__node__.key?(content_attr)
2093
+ else
2094
+ set_attribute(content_attr, "")
2095
+ refs[key] = Array(value)
2096
+ end
2097
+ nil
2098
+ end
2099
+
2100
+ # Drop any explicit ARIA element reference (singular or plural) whose content
2101
+ # attribute was just set directly (so the IDL getter re-resolves the IDREF).
2102
+ def clear_aria_element_ref_for(content_attr)
2103
+ @aria_element_refs&.delete_if { |key, _| aria_element_attr(key) == content_attr }
2104
+ @aria_elements_refs&.delete_if { |key, _| aria_elements_attr(key) == content_attr }
2105
+ end
2106
+
2107
+ # The content attribute a role/ARIA IDL attribute reflects, or nil for a
2108
+ # non-ARIA key. `role` → "role"; `ariaXxx` → "aria-" + the rest, lowercased
2109
+ # with humps removed (`ariaAutoComplete` → "aria-autocomplete",
2110
+ # `ariaColIndexText` → "aria-colindextext").
2111
+ def aria_content_attr(key)
2112
+ return "role" if key == "role"
2113
+ return nil unless key.is_a?(String) && key.length > 4 && key.start_with?("aria")
2114
+ return nil unless key[4] =~ /[A-Z]/
2115
+
2116
+ "aria-#{key[4..].downcase}"
2117
+ end
2118
+
2119
+ # Read a reflected nullable DOMString: the content attribute value, or nil
2120
+ # (→ JS null) when the attribute is absent.
2121
+ def aria_get(content_attr)
2122
+ @__node__.key?(content_attr) ? @__node__[content_attr].to_s : nil
2123
+ end
2124
+
2125
+ # Write a reflected nullable DOMString: null / undefined removes the content
2126
+ # attribute; any other value is ToString-coerced and set.
2127
+ def aria_set(content_attr, value)
2128
+ if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
2129
+ remove_attribute(content_attr) if @__node__.key?(content_attr)
2130
+ else
2131
+ set_attribute(content_attr, value.to_s)
2132
+ end
2133
+ nil
2134
+ end
2135
+
1669
2136
  # Map a JS boolean property name to its underlying HTML attribute.
1670
2137
  # HTML attribute names are lowercase; the DOM property may be
1671
2138
  # camelCase (`readOnly` → `readonly`).
@@ -1676,38 +2143,12 @@ module Dommy
1676
2143
  def __js_set__(key, value)
1677
2144
  case key
1678
2145
  when "textContent"
1679
- # `node.content =` removes all existing children and (if
1680
- # value is non-empty) appends a single text node. Capture
1681
- # before/after to feed MutationObserver — mirrors the
1682
- # innerHTML branch below.
1683
- removed = @__node__.children.to_a
1684
- @__node__.content = value.to_s
1685
- added = @__node__.children.to_a
1686
- if removed.any? || added.any?
1687
- @document.notify_child_list_mutation(
1688
- target_node: @__node__,
1689
- added_nodes: added,
1690
- removed_nodes: removed
1691
- )
1692
- end
2146
+ self.text_content = value
1693
2147
  when "innerHTML"
1694
- removed = @__node__.children.to_a
1695
- if @__node__.name == "template"
1696
- # `<template>` content is invisible to outer selectors in
1697
- # real DOM (it lives in a separate DocumentFragment exposed
1698
- # via `[:content]`). Mirror that here so child placeholders
1699
- # inside the template don't pollute outer queries.
1700
- @document.attach_template_content(self, value.to_s)
1701
- else
1702
- @__node__.inner_html = value.to_s
1703
- @document.migrate_template_descendants(@__node__)
1704
- end
1705
-
1706
- @document.notify_child_list_mutation(
1707
- target_node: @__node__,
1708
- added_nodes: @__node__.children.to_a,
1709
- removed_nodes: removed
1710
- )
2148
+ self.inner_html = value
2149
+ when "outerHTML"
2150
+ # [CEReactions, LegacyNullToEmptyString] DOMString null becomes "".
2151
+ self.outer_html = value.nil? ? "" : value.to_s
1711
2152
  when "hidden", "disabled", "checked", "readOnly", "multiple", "required"
1712
2153
  # Boolean reflected property — funnel through set_attribute /
1713
2154
  # remove_attribute so MutationObserver attribute records fire.
@@ -1720,6 +2161,13 @@ module Dommy
1720
2161
 
1721
2162
  when "className"
1722
2163
  set_attribute("class", value.to_s)
2164
+ when "classList"
2165
+ # WHATWG [PutForwards=value]: `el.classList = x` forwards to
2166
+ # `el.classList.value = x` (set the class attribute). Handling it here
2167
+ # (instead of letting the write fall through as unhandled) stops the JS
2168
+ # bridge from stashing a string expando that would shadow the classList
2169
+ # getter for the rest of the element's life.
2170
+ set_attribute("class", value.to_s)
1723
2171
  when "id"
1724
2172
  set_attribute("id", value.to_s)
1725
2173
  when "value"
@@ -1727,55 +2175,48 @@ module Dommy
1727
2175
  when "slot"
1728
2176
  set_attribute("slot", value.to_s)
1729
2177
  when "role"
1730
- set_attribute("role", value.to_s)
2178
+ aria_set("role", value)
1731
2179
  else
1732
- # `el.onXxx = fn` registers fn as a single named handler.
1733
- # Setting to nil removes it. Mirrors HTMLElement IDL.
1734
- if key.start_with?("on") && key.length > 2
2180
+ if (elements_attr = aria_elements_attr(key))
2181
+ # Plural ARIA element references setter (list of Elements).
2182
+ aria_elements_set(elements_attr, key, value)
2183
+ elsif (element_attr = aria_element_attr(key))
2184
+ # ARIA element-reference IDL attribute setter.
2185
+ aria_element_set(element_attr, key, value)
2186
+ elsif (content_attr = aria_content_attr(key))
2187
+ # ARIA / role reflected nullable DOMString (null/undefined → remove).
2188
+ aria_set(content_attr, value)
2189
+ elsif key.start_with?("on") && key.length > 2
2190
+ # `el.onXxx = fn` registers fn as a single named handler; nil removes.
1735
2191
  set_on_handler(event_name_from_on(key), value)
1736
2192
  else
1737
- nil
2193
+ # Not a known DOM property — tell the JS host to keep it as a
2194
+ # JS-side expando (so object/instance fields keep their identity).
2195
+ Bridge::UNHANDLED
1738
2196
  end
1739
2197
  end
1740
2198
  end
1741
2199
 
1742
- private
1743
-
1744
- def event_name_from_on(key)
1745
- key.to_s.sub(/\Aon/, "").downcase
1746
- end
1747
-
1748
- def set_on_handler(event_name, value)
1749
- @on_handlers ||= {}
1750
- previous = @on_handlers[event_name]
1751
- remove_event_listener(event_name, previous) if previous
1752
- if value
1753
- add_event_listener(event_name, value)
1754
- @on_handlers[event_name] = value
1755
- else
1756
- @on_handlers.delete(event_name)
1757
- end
1758
- end
1759
-
1760
- public
1761
-
1762
- # Methods routed through __js_call__ (keep in sync with its when-arms).
1763
- JS_METHOD_NAMES = %w[
2200
+ include Bridge::Methods
2201
+ js_methods %w[
1764
2202
  getAttribute setAttribute hasAttribute removeAttribute getAttributeNames closest
1765
- querySelector querySelectorAll getElementsByClassName getElementsByTagName
2203
+ getAttributeNS setAttributeNS hasAttributeNS removeAttributeNS getAttributeNodeNS setAttributeNodeNS
2204
+ querySelector querySelectorAll getElementsByClassName getElementsByTagName getElementsByTagNameNS
1766
2205
  insertAdjacentElement insertAdjacentHTML insertAdjacentText toggleAttribute matches
1767
2206
  toString getAttributeNode setAttributeNode removeAttributeNode focus blur attachShadow
1768
2207
  addEventListener removeEventListener dispatchEvent appendChild insertBefore removeChild
1769
2208
  replaceChild cloneNode append prepend replaceChildren before after getInnerHTML getHTML
1770
2209
  remove replaceWith click getBoundingClientRect getClientRects scrollIntoView scroll
1771
- scrollTo scrollBy requestFullscreen showPopover hidePopover togglePopover
1772
- ].freeze
1773
- def __js_method_names__
1774
- JS_METHOD_NAMES
1775
- end
1776
-
2210
+ scrollTo scrollBy requestFullscreen showPopover hidePopover togglePopover isEqualNode
2211
+ hasChildNodes hasAttributes getRootNode normalize contains
2212
+ compareDocumentPosition isSameNode lookupNamespaceURI lookupPrefix isDefaultNamespace
2213
+ ]
1777
2214
  def __js_call__(method, args)
1778
2215
  case method
2216
+ when "hasChildNodes"
2217
+ has_child_nodes?
2218
+ when "hasAttributes"
2219
+ has_attributes?
1779
2220
  when "getAttribute"
1780
2221
  get_attribute(args[0])
1781
2222
  when "setAttribute"
@@ -1784,18 +2225,38 @@ module Dommy
1784
2225
  has_attribute?(args[0])
1785
2226
  when "removeAttribute"
1786
2227
  remove_attribute(args[0])
2228
+ when "getAttributeNS"
2229
+ get_attribute_ns(args[0], args[1])
2230
+ when "setAttributeNS"
2231
+ set_attribute_ns(args[0], args[1], args[2])
2232
+ when "hasAttributeNS"
2233
+ has_attribute_ns?(args[0], args[1])
2234
+ when "removeAttributeNS"
2235
+ remove_attribute_ns(args[0], args[1])
2236
+ when "getAttributeNodeNS"
2237
+ get_attribute_node_ns(args[0], args[1])
2238
+ when "setAttributeNodeNS"
2239
+ set_attribute_node(args[0])
1787
2240
  when "getAttributeNames"
1788
- @__node__.attribute_nodes.map(&:name)
2241
+ get_attribute_names
1789
2242
  when "closest"
2243
+ raise Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
2244
+
1790
2245
  closest(args[0])
1791
2246
  when "querySelector"
1792
- query_selector(args[0])
2247
+ query_selector(Internal.css_query_arg!(args))
1793
2248
  when "querySelectorAll"
1794
- query_selector_all(args[0])
2249
+ query_selector_all(Internal.css_query_arg!(args))
1795
2250
  when "getElementsByClassName"
1796
2251
  get_elements_by_class_name(args[0])
2252
+ when "getElementsByTagNameNS"
2253
+ get_elements_by_tag_name_ns(args[0], args[1])
1797
2254
  when "getElementsByTagName"
1798
2255
  get_elements_by_tag_name(args[0])
2256
+ when "getRootNode"
2257
+ get_root_node
2258
+ when "normalize"
2259
+ normalize
1799
2260
  when "insertAdjacentElement"
1800
2261
  insert_adjacent_element(args[0], args[1])
1801
2262
  when "insertAdjacentHTML"
@@ -1805,7 +2266,23 @@ module Dommy
1805
2266
  when "toggleAttribute"
1806
2267
  toggle_attribute(args[0], args[1])
1807
2268
  when "matches"
2269
+ raise Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
2270
+
1808
2271
  matches?(args[0])
2272
+ when "isEqualNode"
2273
+ is_equal_node(args[0])
2274
+ when "isSameNode"
2275
+ is_same_node(args[0])
2276
+ when "compareDocumentPosition"
2277
+ compare_document_position(args[0])
2278
+ when "lookupNamespaceURI"
2279
+ lookup_namespace_uri(args[0])
2280
+ when "lookupPrefix"
2281
+ lookup_prefix(args[0])
2282
+ when "isDefaultNamespace"
2283
+ is_default_namespace(args[0])
2284
+ when "contains"
2285
+ contains?(args[0])
1809
2286
  when "toString"
1810
2287
  to_s
1811
2288
  when "getAttributeNode"
@@ -1823,7 +2300,7 @@ module Dommy
1823
2300
  when "addEventListener"
1824
2301
  add_event_listener(args[0], args[1], args[2])
1825
2302
  when "removeEventListener"
1826
- remove_event_listener(args[0], args[1])
2303
+ remove_event_listener(args[0], args[1], args[2])
1827
2304
  when "dispatchEvent"
1828
2305
  dispatch_event(args[0])
1829
2306
  when "appendChild"
@@ -1837,9 +2314,9 @@ module Dommy
1837
2314
  when "cloneNode"
1838
2315
  clone_node(args[0])
1839
2316
  when "append"
1840
- append_nodes(args)
2317
+ append(*args)
1841
2318
  when "prepend"
1842
- prepend_nodes(args)
2319
+ prepend(*args)
1843
2320
  when "replaceChildren"
1844
2321
  replace_children(*args)
1845
2322
  when "before"
@@ -1849,91 +2326,30 @@ module Dommy
1849
2326
  when "getInnerHTML", "getHTML"
1850
2327
  inner_html
1851
2328
  when "remove"
1852
- parent = @__node__.parent
1853
- @__node__.unlink
1854
- @document.notify_child_list_mutation(target_node: parent, added_nodes: [], removed_nodes: [@__node__]) if parent
1855
- nil
2329
+ remove
1856
2330
  when "replaceWith"
1857
2331
  replace_with(args)
1858
2332
  when "click"
1859
- dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
2333
+ click
1860
2334
  when "getBoundingClientRect"
1861
- DOMRect.new
2335
+ get_bounding_client_rect
1862
2336
  when "getClientRects"
1863
- []
2337
+ get_client_rects
1864
2338
  when "scrollIntoView", "scroll", "scrollTo", "scrollBy"
1865
- # No layout — record the request for tests to assert against.
1866
- @scroll_log ||= []
1867
- @scroll_log << [method, args]
1868
- nil
2339
+ record_scroll(method, args)
1869
2340
  when "requestFullscreen"
1870
- @document.__internal_set_fullscreen_element__(self)
1871
- PromiseValue.resolve(@document.default_view, nil)
2341
+ request_fullscreen
1872
2342
  when "showPopover"
1873
- toggle_popover_state(true)
1874
- nil
2343
+ show_popover
1875
2344
  when "hidePopover"
1876
- toggle_popover_state(false)
1877
- nil
2345
+ hide_popover
1878
2346
  when "togglePopover"
1879
- new_state = !@__popover_open__
1880
- toggle_popover_state(new_state)
1881
- new_state
2347
+ toggle_popover
1882
2348
  else
1883
2349
  nil
1884
2350
  end
1885
2351
  end
1886
2352
 
1887
- private
1888
-
1889
- def normalize_attr_key(name)
1890
- s = name.to_s
1891
- case_sensitive_attribute_names? ? s : s.downcase
1892
- end
1893
-
1894
- def element_children
1895
- @__node__.element_children.each_with_object([]) do |node, out|
1896
- wrapped = @document.wrap_node(node)
1897
- out << wrapped if wrapped
1898
- end
1899
- end
1900
-
1901
- def wrap_parent(node)
1902
- @document.wrap_node(node)
1903
- end
1904
-
1905
- def __internal_event_parent__
1906
- parent_node = @__node__.parent
1907
- # If our Nokogiri parent is a shadow tree's backing fragment,
1908
- # the bubble path's next stop is the ShadowRoot itself — not
1909
- # the bare Fragment wrapper. The ShadowRoot's __internal_event_parent__
1910
- # will return nil (composed events route to host explicitly).
1911
- if parent_node.is_a?(Backend.document_fragment_class)
1912
- sr = @document.__internal_shadow_root_for_fragment__(parent_node)
1913
- return sr if sr
1914
- end
1915
-
1916
- parent = wrap_parent(parent_node)
1917
- parent || @document
1918
- end
1919
-
1920
- def template_content
1921
- return nil unless @__node__.name == "template"
1922
-
1923
- @document.template_content_fragment(self)
1924
- end
1925
-
1926
- # Attribute name handling depends on the element's namespace:
1927
- # - HTML: case-insensitive (browser DOM stores everything lowercase).
1928
- # - SVG / other XML: case-sensitive (`viewBox` ≠ `viewbox`).
1929
- # Subclasses with a known namespace override `case_sensitive_attribute_names?`
1930
- # to flip the behavior. Generic Element nodes inspect the namespace
1931
- # URI directly.
1932
- def case_sensitive_attribute_names?
1933
- ns = namespace_uri
1934
- !ns.nil? && ns != "http://www.w3.org/1999/xhtml"
1935
- end
1936
-
1937
2353
  def get_attribute(name)
1938
2354
  return nil if name.nil?
1939
2355
 
@@ -1943,9 +2359,17 @@ module Dommy
1943
2359
  def set_attribute(name, value)
1944
2360
  return nil if name.nil?
1945
2361
 
2362
+ # WHATWG: a qualifiedName not matching the Name production throws.
2363
+ # The WPT corpus exercises only the empty string here (other shapes
2364
+ # like "0"/":"/"invalid^Name" are deliberately treated as valid).
2365
+ raise DOMException::InvalidCharacterError, "empty attribute name" if name.to_s.empty?
2366
+
1946
2367
  key = normalize_attr_key(name)
1947
2368
  old = @__node__[key]
1948
2369
  @__node__[key] = value.to_s
2370
+ # A direct write to an `aria-*` IDREF attribute drops any explicitly-set
2371
+ # element reference, so the IDL getter re-resolves the new IDREF.
2372
+ clear_aria_element_ref_for(key) if key.start_with?("aria-")
1949
2373
  @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1950
2374
  nil
1951
2375
  end
@@ -1963,17 +2387,75 @@ module Dommy
1963
2387
  return nil unless @__node__.key?(key)
1964
2388
 
1965
2389
  old = @__node__[key]
2390
+ # Detach the cached Attr (caching its value) *before* the backend drop,
2391
+ # so a held reference keeps the value it had when removed.
2392
+ @attributes&.__internal_evict__(nil, key)
1966
2393
  @__node__.remove_attribute(key)
2394
+ # Removing an `aria-*` IDREF attribute also clears any explicitly-set
2395
+ # element reference (the IDL getter then returns null).
2396
+ clear_aria_element_ref_for(key) if key.start_with?("aria-")
1967
2397
  @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1968
2398
  nil
1969
2399
  end
1970
2400
 
2401
+ # ----- Namespaced attributes (DOM *AttributeNS) -----
2402
+
2403
+ def get_attribute_ns(namespace, local_name)
2404
+ return nil if local_name.nil?
2405
+
2406
+ ns = namespace.to_s
2407
+ Backend.get_attribute_ns(@__node__, ns.empty? ? nil : ns, local_name.to_s)
2408
+ end
2409
+
2410
+ def has_attribute_ns?(namespace, local_name)
2411
+ return false if local_name.nil?
2412
+
2413
+ ns = namespace.to_s
2414
+ Backend.has_attribute_ns?(@__node__, ns.empty? ? nil : ns, local_name.to_s)
2415
+ end
2416
+
2417
+ def set_attribute_ns(namespace, qualified_name, value)
2418
+ ns, prefix, local = Internal::Namespaces.validate_and_extract(namespace, qualified_name)
2419
+ old = Backend.get_attribute_ns(@__node__, ns, local)
2420
+ Backend.set_attribute_ns(@__node__, ns, prefix, local, qualified_name.to_s, value.to_s)
2421
+ @document.notify_attribute_mutation(target_node: @__node__, attribute_name: local, old_value: old, namespace: ns)
2422
+ nil
2423
+ end
2424
+
2425
+ def remove_attribute_ns(namespace, local_name)
2426
+ return nil if local_name.nil?
2427
+
2428
+ ns = namespace.to_s
2429
+ ns = nil if ns.empty?
2430
+ local = local_name.to_s
2431
+ old = Backend.get_attribute_ns(@__node__, ns, local)
2432
+ @attributes&.__internal_evict__(ns, local)
2433
+ Backend.remove_attribute_ns(@__node__, ns, local)
2434
+ if old
2435
+ @document.notify_attribute_mutation(target_node: @__node__, attribute_name: local, old_value: old, namespace: ns)
2436
+ end
2437
+ nil
2438
+ end
2439
+
2440
+ def get_attribute_node_ns(namespace, local_name)
2441
+ attributes.get_named_item_ns(namespace, local_name)
2442
+ end
2443
+
1971
2444
  def closest(selector)
1972
- return nil if selector.nil? || selector.to_s.empty?
2445
+ return nil if selector.nil?
2446
+ Internal.validate_selector!(selector)
2447
+
2448
+ # Elements matching the selector (scoped to this element, so `:scope`
2449
+ # resolves here), then return the nearest inclusive ancestor among them.
2450
+ handler = Internal.scoped_pseudo_handlers(@__node__)
2451
+ safe = Internal.backend_safe_selector(selector.to_s)
2452
+ matched = with_selector_errors(selector) do
2453
+ @document.nokogiri_doc.css(safe, handler).map(&:pointer_id)
2454
+ end
1973
2455
 
1974
2456
  node = @__node__
1975
2457
  while node&.element?
1976
- return @document.wrap_node(node) if matches_selector?(node, selector.to_s)
2458
+ return @document.wrap_node(node) if matched.include?(node.pointer_id)
1977
2459
 
1978
2460
  node = node.parent
1979
2461
  end
@@ -1981,6 +2463,23 @@ module Dommy
1981
2463
  nil
1982
2464
  end
1983
2465
 
2466
+ # Map Nokogiri's selector errors to spec behavior:
2467
+ # - a CSS *parse* error ("unexpected … after …") means the selector is
2468
+ # syntactically invalid → SyntaxError (querySelector/closest must throw);
2469
+ # - an "Unregistered function" means a valid pseudo Nokogiri compiled but
2470
+ # can't evaluate (`:hover`, `:invalid`, …) → degrade to matching nothing.
2471
+ def with_selector_errors(selector)
2472
+ yield
2473
+ rescue ::StandardError => e
2474
+ return [] if e.message.include?("Unregistered function")
2475
+
2476
+ if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e.message.include?("unexpected")
2477
+ raise DOMException::SyntaxError, "'#{selector}' is not a valid selector."
2478
+ end
2479
+
2480
+ raise
2481
+ end
2482
+
1984
2483
  # Web Animations: start an animation on this element.
1985
2484
  # Returns the new Animation. Dommy doesn't interpolate; the
1986
2485
  # animation simply transitions through the `playState` lifecycle,
@@ -2002,15 +2501,39 @@ module Dommy
2002
2501
  alias getAnimations get_animations
2003
2502
 
2004
2503
  def query_selector(selector)
2005
- return nil if selector.nil? || selector.to_s.empty?
2504
+ return nil if selector.nil?
2505
+ # The empty string is not a valid selector (an explicit DOMString "" is a
2506
+ # SyntaxError; `null` coerces to "null" and is handled above as nil).
2507
+ Internal.validate_selector!(selector)
2006
2508
 
2007
- @document.wrap_node(@__node__.at_css(selector.to_s, Internal::CSS_PSEUDO_HANDLERS))
2509
+ @document.wrap_node(scoped_query(selector.to_s).first)
2008
2510
  end
2009
2511
 
2010
2512
  def query_selector_all(selector)
2011
- return NodeList.new if selector.nil? || selector.to_s.empty?
2012
-
2013
- NodeList.new(@__node__.css(selector.to_s, Internal::CSS_PSEUDO_HANDLERS).map { |node| @document.wrap_node(node) }.compact)
2513
+ return NodeList.new if selector.nil?
2514
+ Internal.validate_selector!(selector)
2515
+
2516
+ NodeList.new(scoped_query(selector.to_s).map { |node| @document.wrap_node(node) }.compact)
2517
+ end
2518
+
2519
+ # Run a CSS query rooted at this element. A `:scope` selector must resolve to
2520
+ # this element, but Nokogiri scopes `el.css` to descendants (`.//`), which
2521
+ # excludes the element itself — so for `:scope` queries we evaluate against
2522
+ # the whole document (where this element IS reachable) and restrict the
2523
+ # results to this element's own subtree.
2524
+ def scoped_query(sel)
2525
+ sel = Internal.backend_safe_selector(sel)
2526
+ handler = Internal.scoped_pseudo_handlers(@__node__)
2527
+ with_selector_errors(sel) do
2528
+ if sel.include?(":scope")
2529
+ self_id = @__node__.pointer_id
2530
+ @document.nokogiri_doc.css(sel, handler).select do |n|
2531
+ n.ancestors.any? { |a| a.pointer_id == self_id }
2532
+ end
2533
+ else
2534
+ @__node__.css(sel, handler)
2535
+ end
2536
+ end
2014
2537
  end
2015
2538
 
2016
2539
  # XPath queries scoped to this element, returning wrapped nodes.
@@ -2028,14 +2551,6 @@ module Dommy
2028
2551
  @__node__.path
2029
2552
  end
2030
2553
 
2031
- def append_child(child)
2032
- check_hierarchy!(child)
2033
- nodes = detach_dom_nodes(child)
2034
- append_dom_nodes(nodes)
2035
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
2036
- child
2037
- end
2038
-
2039
2554
  def insert_before(child, reference)
2040
2555
  check_hierarchy!(child)
2041
2556
  nodes = detach_dom_nodes(child)
@@ -2053,7 +2568,7 @@ module Dommy
2053
2568
  end
2054
2569
  end
2055
2570
 
2056
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
2571
+ notify_child_list(added: nodes)
2057
2572
  child
2058
2573
  end
2059
2574
 
@@ -2063,8 +2578,7 @@ module Dommy
2063
2578
  raise DOMException::NotFoundError, "node is not a child of this element"
2064
2579
  end
2065
2580
 
2066
- node.unlink
2067
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [], removed_nodes: [node])
2581
+ @document.remove_node_with_notify(node)
2068
2582
  child
2069
2583
  end
2070
2584
 
@@ -2080,50 +2594,102 @@ module Dommy
2080
2594
  new_nodes = detach_dom_nodes(new_child)
2081
2595
  new_nodes.reverse_each { |node| old_node.add_previous_sibling(node) }
2082
2596
  old_node.unlink
2083
- @document.notify_child_list_mutation(
2084
- target_node: @__node__,
2085
- added_nodes: new_nodes,
2086
- removed_nodes: [old_node]
2087
- )
2597
+ notify_child_list(added: new_nodes, removed: [old_node])
2088
2598
  old_child
2089
2599
  end
2090
2600
 
2091
2601
  def clone_node(deep_arg)
2092
- deep = !!deep_arg
2093
- if deep
2094
- @document.wrap_node(
2095
- Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc).children.find(&:element?)
2096
- )
2602
+ # Copy the node in place via libxml's deep dup, NOT by re-parsing to_html as
2603
+ # a fragment: the HTML fragment parser unwraps `<body>` / `<head>` /
2604
+ # `<html>`, so cloning a body produced its children, not a body element
2605
+ # (which broke Turbo's snapshot cache — it clones the body and restores it
2606
+ # via documentElement.replaceChild on back/forward). dup(1) preserves the
2607
+ # element's namespace and attributes (createElement would lose the
2608
+ # namespace); for a shallow clone we keep that node but drop its subtree.
2609
+ copy = @__node__.dup(1)
2610
+ copy.children.each(&:unlink) unless deep_arg
2611
+ @document.wrap_node(copy)
2612
+ end
2613
+
2614
+ # Test inspector for scroll calls (no real layout to scroll).
2615
+ def __test_scroll_log__
2616
+ @scroll_log ||= []
2617
+ end
2618
+
2619
+ # ---- Internal helpers (single private section) ----
2620
+ private
2621
+
2622
+ def attribute_signature
2623
+ Backend.attribute_nodes(@__node__).map { |a| [a.name, a.value] }.sort
2624
+ end
2625
+
2626
+ # on* event-handler property helpers.
2627
+ def event_name_from_on(key)
2628
+ key.to_s.sub(/\Aon/, "").downcase
2629
+ end
2630
+
2631
+ def set_on_handler(event_name, value)
2632
+ @on_handlers ||= {}
2633
+ previous = @on_handlers[event_name]
2634
+ remove_event_listener(event_name, previous) if previous
2635
+ if value
2636
+ add_event_listener(event_name, value)
2637
+ @on_handlers[event_name] = value
2097
2638
  else
2098
- clone = @document.create_element(@__node__.name)
2099
- @__node__.attribute_nodes.each do |attr|
2100
- clone.__js_call__("setAttribute", [attr.name, attr.value])
2101
- end
2639
+ @on_handlers.delete(event_name)
2640
+ end
2641
+ end
2102
2642
 
2103
- clone
2643
+ # Attribute-key / child-wrapping / event-parent helpers.
2644
+ def normalize_attr_key(name)
2645
+ s = name.to_s
2646
+ case_sensitive_attribute_names? ? s : s.downcase
2647
+ end
2648
+
2649
+ def element_children
2650
+ @__node__.element_children.each_with_object([]) do |node, out|
2651
+ wrapped = @document.wrap_node(node)
2652
+ out << wrapped if wrapped
2104
2653
  end
2105
2654
  end
2106
2655
 
2107
- def append_nodes(args)
2108
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
2109
- append_dom_nodes(nodes)
2110
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
2111
- nil
2656
+ def wrap_parent(node)
2657
+ @document.wrap_node(node)
2112
2658
  end
2113
2659
 
2114
- def prepend_nodes(args)
2115
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
2116
- anchor = @__node__.children.first
2117
- if anchor
2118
- nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
2119
- else
2120
- append_dom_nodes(nodes)
2660
+ def __internal_event_parent__
2661
+ parent_node = @__node__.parent
2662
+ # If our Nokogiri parent is a shadow tree's backing fragment,
2663
+ # the bubble path's next stop is the ShadowRoot itself — not
2664
+ # the bare Fragment wrapper. The ShadowRoot's __internal_event_parent__
2665
+ # will return nil (composed events route to host explicitly).
2666
+ if parent_node.is_a?(Backend.document_fragment_class)
2667
+ sr = @document.__internal_shadow_root_for_fragment__(parent_node)
2668
+ return sr if sr
2121
2669
  end
2122
2670
 
2123
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
2124
- nil
2671
+ parent = wrap_parent(parent_node)
2672
+ parent || @document
2125
2673
  end
2126
2674
 
2675
+ def template_content
2676
+ return nil unless @__node__.name == "template"
2677
+
2678
+ @document.template_content_fragment(self)
2679
+ end
2680
+
2681
+ # Attribute name handling depends on the element's namespace:
2682
+ # - HTML: case-insensitive (browser DOM stores everything lowercase).
2683
+ # - SVG / other XML: case-sensitive (`viewBox` ≠ `viewbox`).
2684
+ # Subclasses with a known namespace override `case_sensitive_attribute_names?`
2685
+ # to flip the behavior. Generic Element nodes inspect the namespace
2686
+ # URI directly.
2687
+ def case_sensitive_attribute_names?
2688
+ ns = namespace_uri
2689
+ !ns.nil? && ns != "http://www.w3.org/1999/xhtml"
2690
+ end
2691
+
2692
+ # Insertion / scroll / popover helpers.
2127
2693
  def insert_adjacent(side, args)
2128
2694
  parent = @__node__.parent
2129
2695
  return nil unless parent
@@ -2141,7 +2707,7 @@ module Dommy
2141
2707
  end
2142
2708
  end
2143
2709
 
2144
- @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [])
2710
+ notify_child_list(added: nodes, target: parent)
2145
2711
  nil
2146
2712
  end
2147
2713
 
@@ -2159,7 +2725,7 @@ module Dommy
2159
2725
  nodes.each { |node| parent.add_child(node) }
2160
2726
  end
2161
2727
 
2162
- @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [removed])
2728
+ notify_child_list(added: nodes, removed: [removed], target: parent)
2163
2729
  nil
2164
2730
  end
2165
2731
 
@@ -2167,6 +2733,12 @@ module Dommy
2167
2733
  nodes.each { |node| @__node__.add_child(node) }
2168
2734
  end
2169
2735
 
2736
+ # ParentNode hook: Element enforces the no-cycle hierarchy check that
2737
+ # Fragment / ShadowRoot skip.
2738
+ def check_insertion!(child)
2739
+ check_hierarchy!(child)
2740
+ end
2741
+
2170
2742
  # Raise HierarchyRequestError when the proposed insertion would
2171
2743
  # produce a cycle (inserting an ancestor as a descendant of
2172
2744
  # itself). Strings and Fragments are always safe.
@@ -2188,25 +2760,6 @@ module Dommy
2188
2760
  detach_dom_nodes(value).first
2189
2761
  end
2190
2762
 
2191
- def detach_dom_nodes(value)
2192
- case value
2193
- when Element, TextNode, CommentNode
2194
- node = value.__dommy_backend_node__
2195
- node.unlink if node.parent
2196
- [node]
2197
- when Fragment
2198
- value.extract_children
2199
- when String
2200
- [@document.create_text_node(value).__dommy_backend_node__]
2201
- else
2202
- node = unwrap_dom_node(value)
2203
- return [] unless node
2204
-
2205
- node.unlink if node.parent
2206
- [node]
2207
- end
2208
- end
2209
-
2210
2763
  def unwrap_dom_node(value)
2211
2764
  return value.__dommy_backend_node__ if value.respond_to?(:__dommy_backend_node__)
2212
2765
 
@@ -2221,6 +2774,13 @@ module Dommy
2221
2774
  end
2222
2775
  end
2223
2776
 
2777
+ # No real layout — record the scroll request so tests can assert it.
2778
+ def record_scroll(name, args)
2779
+ @scroll_log ||= []
2780
+ @scroll_log << [name, args]
2781
+ nil
2782
+ end
2783
+
2224
2784
  # Popover state — modern HTML pattern. `show`/`hide`/`toggle`
2225
2785
  # fire `beforetoggle` and `toggle` events (no real visual change).
2226
2786
  def toggle_popover_state(open)
@@ -2242,37 +2802,5 @@ module Dommy
2242
2802
  )
2243
2803
  )
2244
2804
  end
2245
-
2246
- # Test inspector for scroll calls (no real layout to scroll).
2247
- def __test_scroll_log__
2248
- @scroll_log ||= []
2249
- end
2250
-
2251
- public :__test_scroll_log__
2252
-
2253
- # Re-expose snake_case methods that the JS bridge dispatch routes
2254
- # to. Defined as private originally so internal helpers (element_children,
2255
- # detach_dom_nodes, etc.) stay encapsulated; CRuby users call these
2256
- # as the public Ruby API.
2257
- public(
2258
- :get_attribute,
2259
- :set_attribute,
2260
- :has_attribute?,
2261
- :remove_attribute,
2262
- :append_child,
2263
- :insert_before,
2264
- :remove_child,
2265
- :replace_child,
2266
- :clone_node,
2267
- :query_selector,
2268
- :query_selector_all,
2269
- :at_xpath,
2270
- :xpath,
2271
- :path,
2272
- :closest,
2273
- :animate,
2274
- :get_animations,
2275
- :getAnimations
2276
- )
2277
2805
  end
2278
2806
  end