dommy 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
data/lib/dommy/element.rb CHANGED
@@ -8,8 +8,11 @@ module Dommy
8
8
  class Fragment
9
9
  include EventTarget
10
10
  include Node
11
+ include Internal::ParentNode
11
12
 
12
- attr_reader :__node__, :document
13
+ attr_reader :document
14
+
15
+ def __dommy_backend_node__ = @__node__
13
16
 
14
17
  def initialize(document, nokogiri_node)
15
18
  @document = document
@@ -26,8 +29,12 @@ module Dommy
26
29
  @__node__.element_children.size
27
30
  end
28
31
 
32
+ # Live, cached childNodes so `fragment.childNodes === fragment.childNodes` and
33
+ # later mutations are reflected (WHATWG live NodeList).
29
34
  def child_nodes
30
- 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
31
38
  end
32
39
 
33
40
  def first_child
@@ -50,23 +57,18 @@ module Dommy
50
57
  @__node__.text
51
58
  end
52
59
 
53
- def append_child(child)
54
- nodes = detach_dom_nodes(child)
55
- nodes.each { |n| @__node__.add_child(n) }
56
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
57
- child
58
- end
59
-
60
60
  def query_selector(selector)
61
- return nil if selector.nil? || selector.to_s.empty?
61
+ return nil if selector.nil?
62
+ Internal.validate_selector!(selector)
62
63
 
63
- @document.wrap_node(@__node__.at_css(selector.to_s))
64
+ @document.wrap_node(@__node__.at_css(Internal.backend_safe_selector(selector.to_s)))
64
65
  end
65
66
 
66
67
  def query_selector_all(selector)
67
- return NodeList.new if selector.nil? || selector.to_s.empty?
68
+ return NodeList.new if selector.nil?
69
+ Internal.validate_selector!(selector)
68
70
 
69
- 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)
70
72
  end
71
73
 
72
74
  def get_element_by_id(id)
@@ -79,6 +81,8 @@ module Dommy
79
81
  case key
80
82
  when "nodeType"
81
83
  11
84
+ when "nodeName"
85
+ "#document-fragment"
82
86
  when "children"
83
87
  element_children
84
88
  when "childNodes"
@@ -95,23 +99,69 @@ module Dommy
95
99
  last_element_child
96
100
  when "textContent"
97
101
  @__node__.text
102
+ when "ownerDocument"
103
+ @document
98
104
  end
99
105
  end
100
106
 
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]
101
113
  def __js_call__(method, args)
102
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])
103
125
  when "cloneNode"
104
126
  deep = args.empty? ? false : !!args[0]
105
127
  deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc)) : @document
106
128
  .wrap_node(Parser.fragment("", owner_doc: @document.nokogiri_doc))
107
129
  when "querySelector"
108
- query_selector(args[0])
130
+ query_selector(Internal.css_query_arg!(args))
109
131
  when "querySelectorAll"
110
- query_selector_all(args[0])
132
+ query_selector_all(Internal.css_query_arg!(args))
111
133
  when "getElementById"
112
134
  get_element_by_id(args[0])
113
135
  when "appendChild"
114
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])
115
165
  else
116
166
  nil
117
167
  end
@@ -123,21 +173,50 @@ module Dommy
123
173
  nodes
124
174
  end
125
175
 
126
- 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__
127
181
 
128
- def detach_dom_nodes(value)
129
- case value
130
- when String
131
- [@document.create_text_node(value).__node__]
132
- else
133
- node = value.respond_to?(:__node__) ? value.__node__ : nil
134
- return [] unless node
182
+ bn.unlink
183
+ node
184
+ end
135
185
 
136
- node.unlink if node.parent
137
- [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) }
138
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
139
216
  end
140
217
 
218
+ private
219
+
141
220
  def element_children
142
221
  @__node__.element_children.each_with_object([]) do |node, out|
143
222
  wrapped = @document.wrap_node(node)
@@ -147,7 +226,7 @@ module Dommy
147
226
 
148
227
  # Fragments aren't part of the bubble chain; nil terminates
149
228
  # bubbling at the boundary (shadow root, detached fragment, etc.).
150
- def __event_parent__
229
+ def __internal_event_parent__
151
230
  nil
152
231
  end
153
232
  end
@@ -156,8 +235,29 @@ module Dommy
156
235
  # nodeValue / textContent API and `remove` / `cloneNode` semantics.
157
236
  class CharacterDataNode
158
237
  include Node
238
+ include EventTarget
239
+
240
+ def __dommy_backend_node__ = @__node__
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
159
247
 
160
- attr_reader :__node__
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
161
261
 
162
262
  def initialize(document, nokogiri_node)
163
263
  @document = document
@@ -191,7 +291,7 @@ module Dommy
191
291
  end
192
292
 
193
293
  def remove
194
- @__node__.unlink
294
+ @document.remove_node_with_notify(@__node__)
195
295
  nil
196
296
  end
197
297
 
@@ -215,22 +315,83 @@ module Dommy
215
315
  __js_set__(key.to_s, value)
216
316
  end
217
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
+
218
365
  def __js_get__(key)
219
366
  case key
220
367
  when "nodeType"
221
368
  node_type
369
+ when "nodeName"
370
+ node_name
222
371
  when "textContent"
223
372
  @__node__.content
224
373
  when "data"
225
374
  @__node__.content
226
375
  when "nodeValue"
227
376
  @__node__.content
377
+ when "length"
378
+ length
228
379
  when "parentNode"
229
380
  parent_node
381
+ when "ownerDocument"
382
+ @document
230
383
  when "nextSibling"
231
384
  next_sibling
232
385
  when "previousSibling"
233
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
234
395
  end
235
396
  end
236
397
 
@@ -243,16 +404,151 @@ module Dommy
243
404
  nil
244
405
  end
245
406
 
246
- def __js_call__(method, _args)
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]
414
+ def __js_call__(method, args)
247
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])
248
459
  when "remove"
249
- @__node__.unlink
250
- nil
460
+ remove
461
+ when "before"
462
+ before(*args)
463
+ when "after"
464
+ after(*args)
465
+ when "replaceWith"
466
+ replace_with(*args)
467
+ when "isEqualNode"
468
+ is_equal_node(args[0])
469
+ end
470
+ end
471
+
472
+ # ChildNode mixin: WHATWG DOM defines `before`, `after`,
473
+ # `replaceWith` on all child nodes, including Text and Comment.
474
+ # Implementations operate on the Nokogiri layer and notify the
475
+ # MutationObserver with the underlying nodes (mirroring
476
+ # Element#remove_child / replace_child).
477
+
478
+ def before(*args)
479
+ parent = @__node__.parent
480
+ return nil unless parent
481
+
482
+ added = args.map { |arg| coerce_node(arg) }.compact
483
+ added.reverse_each { |node| @__node__.add_previous_sibling(node) }
484
+ notify_child_list_added(parent, added)
485
+ nil
486
+ end
487
+
488
+ def after(*args)
489
+ parent = @__node__.parent
490
+ return nil unless parent
491
+
492
+ added = args.map { |arg| coerce_node(arg) }.compact
493
+ anchor = @__node__.next_sibling
494
+ if anchor
495
+ added.reverse_each { |node| anchor.add_previous_sibling(node) }
496
+ else
497
+ added.each { |node| parent.add_child(node) }
498
+ end
499
+ notify_child_list_added(parent, added)
500
+ nil
501
+ end
502
+
503
+ def replace_with(*args)
504
+ parent = @__node__.parent
505
+ return nil unless parent
506
+
507
+ added = args.map { |arg| coerce_node(arg) }.compact
508
+ removed = @__node__
509
+ anchor = @__node__.next_sibling
510
+ @__node__.unlink
511
+ if anchor
512
+ added.reverse_each { |node| anchor.add_previous_sibling(node) }
513
+ else
514
+ added.each { |node| parent.add_child(node) }
251
515
  end
516
+ @document.notify_child_list_mutation(
517
+ target_node: parent,
518
+ added_nodes: added,
519
+ removed_nodes: [removed]
520
+ )
521
+ nil
252
522
  end
253
523
 
254
524
  private
255
525
 
526
+ # Coerce a `before` / `after` / `replaceWith` argument into a raw
527
+ # Nokogiri node, ready to be linked into a parent. Strings become
528
+ # fresh text nodes; existing nodes are detached from their current
529
+ # parent first (matching Element#detach_dom_nodes minus the
530
+ # Fragment branch which is rarely needed off a text/comment node).
531
+ def coerce_node(arg)
532
+ case arg
533
+ when String
534
+ @document.create_text_node(arg).__dommy_backend_node__
535
+ else
536
+ node = arg.respond_to?(:__dommy_backend_node__) ? arg.__dommy_backend_node__ : nil
537
+ node.unlink if node && node.parent
538
+ node
539
+ end
540
+ end
541
+
542
+ def notify_child_list_added(parent, added)
543
+ return if added.empty?
544
+
545
+ @document.notify_child_list_mutation(
546
+ target_node: parent,
547
+ added_nodes: added,
548
+ removed_nodes: []
549
+ )
550
+ end
551
+
256
552
  def write_data(value)
257
553
  old = @__node__.content
258
554
  @__node__.content = value.to_s
@@ -265,6 +561,8 @@ module Dommy
265
561
  3
266
562
  end
267
563
 
564
+ # Own __js_call__ methods, on top of CharacterDataNode's.
565
+ js_methods %w[cloneNode]
268
566
  def __js_call__(method, args)
269
567
  case method
270
568
  when "cloneNode"
@@ -275,11 +573,21 @@ module Dommy
275
573
  end
276
574
  end
277
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
+
278
584
  class CommentNode < CharacterDataNode
279
585
  def node_type
280
586
  8
281
587
  end
282
588
 
589
+ # Own __js_call__ methods, on top of CharacterDataNode's.
590
+ js_methods %w[cloneNode]
283
591
  def __js_call__(method, args)
284
592
  case method
285
593
  when "cloneNode"
@@ -296,8 +604,11 @@ module Dommy
296
604
  class ClassList
297
605
  include Enumerable
298
606
 
299
- 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")
300
610
  @element = element
611
+ @attribute = attribute
301
612
  end
302
613
 
303
614
  def length
@@ -307,15 +618,18 @@ module Dommy
307
618
  alias size length
308
619
 
309
620
  def item(index)
310
- class_tokens[index.to_i]
621
+ i = index.to_i
622
+ return nil if i.negative?
623
+
624
+ class_tokens[i]
311
625
  end
312
626
 
313
627
  def value
314
- @element.__node__["class"].to_s
628
+ @element.__dommy_backend_node__[@attribute].to_s
315
629
  end
316
630
 
317
631
  def value=(new_value)
318
- @element.set_attribute("class", new_value.to_s)
632
+ @element.set_attribute(@attribute, new_value.to_s)
319
633
  end
320
634
 
321
635
  # Spec: contains() does NOT validate (no SyntaxError on empty).
@@ -334,14 +648,22 @@ module Dommy
334
648
  end
335
649
 
336
650
  def replace(old_token, new_token)
337
- old_s = validate_token(old_token)
338
- 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
+
339
661
  tokens = class_tokens
340
662
  idx = tokens.index(old_s)
341
663
  return false unless idx
342
664
 
343
665
  tokens[idx] = new_s
344
- @element.set_attribute("class", tokens.uniq.join(" "))
666
+ @element.set_attribute(@attribute, tokens.uniq.join(" "))
345
667
  true
346
668
  end
347
669
 
@@ -368,8 +690,14 @@ module Dommy
368
690
  when "value"
369
691
  value
370
692
  else
371
- if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
372
- 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
373
701
  end
374
702
  end
375
703
  end
@@ -383,22 +711,30 @@ module Dommy
383
711
  nil
384
712
  end
385
713
 
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]
386
719
  def __js_call__(method, args)
387
720
  case method
388
721
  when "add"
389
722
  update_tokens { |tokens| tokens | normalize_tokens(args) }
390
- nil
723
+ Bridge::UNDEFINED
391
724
  when "remove"
392
725
  update_tokens { |tokens| tokens - normalize_tokens(args) }
393
- nil
726
+ Bridge::UNDEFINED
394
727
  when "contains"
395
- 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]))
396
730
  when "toggle"
397
731
  toggle(args[0], args[1])
398
732
  when "replace"
399
733
  replace(args[0], args[1])
400
734
  when "item"
401
735
  item(args[0])
736
+ when "toString"
737
+ value
402
738
  else
403
739
  nil
404
740
  end
@@ -409,52 +745,63 @@ module Dommy
409
745
  def toggle(token, force)
410
746
  name = validate_token(token)
411
747
  present = class_tokens.include?(name)
412
- if force.nil?
413
- desired = !present
414
- else
415
- desired = !!force
416
- end
748
+ force_given = !(force.nil? || force.equal?(Bridge::UNDEFINED))
749
+
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
417
756
 
418
- update_tokens do |tokens|
419
- desired ? (tokens | [name]) : (tokens - [name])
757
+ update_tokens { |tokens| want ? tokens | [name] : tokens - [name] }
758
+ return want
420
759
  end
421
760
 
761
+ desired = !present
762
+ update_tokens { |tokens| desired ? tokens | [name] : tokens - [name] }
422
763
  desired
423
764
  end
424
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
+
425
772
  # Spec: any empty-string argument throws SyntaxError; any token
426
773
  # containing ASCII whitespace throws InvalidCharacterError. Applies
427
774
  # to add / remove / replace / toggle.
428
775
  def normalize_tokens(args)
429
- args.map do |t|
430
- s = t.to_s
431
- raise DOMException::SyntaxError, "token is empty" if s.empty?
432
- raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
433
-
434
- s
435
- end
776
+ args.map { |t| validate_token(t) }
436
777
  end
437
778
 
438
779
  def validate_token(token)
439
- s = token.to_s
780
+ s = stringify_token(token)
440
781
  raise DOMException::SyntaxError, "token is empty" if s.empty?
441
782
  raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
442
783
 
443
784
  s
444
785
  end
445
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.
446
791
  def class_tokens
447
- raw = @element.__node__["class"].to_s
448
- raw.split(/\s+/).reject(&:empty?)
792
+ raw = @element.__dommy_backend_node__[@attribute].to_s
793
+ raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
449
794
  end
450
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.
451
800
  def update_tokens
452
801
  tokens = yield(class_tokens)
453
- if tokens.empty?
454
- @element.remove_attribute("class") if @element.__node__.key?("class")
455
- else
456
- @element.set_attribute("class", tokens.join(" "))
457
- end
802
+ return if tokens.empty? && !@element.__dommy_backend_node__.key?(@attribute)
803
+
804
+ @element.set_attribute(@attribute, tokens.join(" "))
458
805
  end
459
806
  end
460
807
 
@@ -467,7 +814,7 @@ module Dommy
467
814
  end
468
815
 
469
816
  def __js_get__(key)
470
- @element.__node__[attr_name(key)]
817
+ @element.__dommy_backend_node__[attr_name(key)]
471
818
  end
472
819
 
473
820
  def __js_set__(key, value)
@@ -475,10 +822,28 @@ module Dommy
475
822
  nil
476
823
  end
477
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
+
478
831
  def __js_call__(_method, _args)
479
832
  nil
480
833
  end
481
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
+
482
847
  private
483
848
 
484
849
  def attr_name(key)
@@ -629,6 +994,8 @@ module Dommy
629
994
  nil
630
995
  end
631
996
 
997
+ include Bridge::Methods
998
+ js_methods %w[setProperty removeProperty getPropertyValue item]
632
999
  def __js_call__(method, args)
633
1000
  case method
634
1001
  when "setProperty"
@@ -674,7 +1041,7 @@ module Dommy
674
1041
  end
675
1042
 
676
1043
  def properties
677
- raw = @element.__node__["style"].to_s
1044
+ raw = @element.__dommy_backend_node__["style"].to_s
678
1045
  raw.split(";").each_with_object({}) do |entry, out|
679
1046
  key, value = entry.split(":", 2)
680
1047
  next unless key && value
@@ -685,7 +1052,7 @@ module Dommy
685
1052
 
686
1053
  def write_properties(props)
687
1054
  if props.empty?
688
- @element.remove_attribute("style") if @element.__node__.key?("style")
1055
+ @element.remove_attribute("style") if @element.__dommy_backend_node__.key?("style")
689
1056
  else
690
1057
  @element.set_attribute("style", props.map { |k, v| "#{k}:#{v}" }.join(";"))
691
1058
  end
@@ -695,8 +1062,11 @@ module Dommy
695
1062
  class Element
696
1063
  include EventTarget
697
1064
  include Node
1065
+ include Internal::ParentNode
698
1066
 
699
- attr_reader :__node__, :document
1067
+ attr_reader :document
1068
+
1069
+ def __dommy_backend_node__ = @__node__
700
1070
 
701
1071
  def initialize(document, nokogiri_node)
702
1072
  @document = document
@@ -712,6 +1082,11 @@ module Dommy
712
1082
  @live_children = HTMLCollection.new do
713
1083
  @__node__.element_children.map { |n| @document.wrap_node(n) }.compact
714
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
715
1090
  end
716
1091
 
717
1092
  # ----- Public Ruby API (snake_case) -----
@@ -726,19 +1101,61 @@ module Dommy
726
1101
  end
727
1102
 
728
1103
  def text_content=(value)
729
- __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)
730
1111
  end
731
1112
 
732
1113
  def inner_html
733
- __js_get__("innerHTML")
1114
+ if @__node__.name == "template"
1115
+ @document.template_content_inner_html(self)
1116
+ else
1117
+ @__node__.inner_html
1118
+ end
734
1119
  end
735
1120
 
736
1121
  def inner_html=(value)
737
- __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
738
1145
  end
739
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.
740
1151
  def tag_name
741
- @__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
742
1159
  end
743
1160
 
744
1161
  def id
@@ -761,6 +1178,33 @@ module Dommy
761
1178
  @class_list
762
1179
  end
763
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
+
764
1208
  def style
765
1209
  @style
766
1210
  end
@@ -820,7 +1264,7 @@ module Dommy
820
1264
  end
821
1265
 
822
1266
  def has_attributes?
823
- @__node__.attribute_nodes.any?
1267
+ Backend.attribute_nodes(@__node__).any?
824
1268
  end
825
1269
 
826
1270
  def next_sibling
@@ -860,7 +1304,7 @@ module Dommy
860
1304
  parent = @__node__.parent
861
1305
  return unless parent
862
1306
 
863
- if parent.is_a?(Nokogiri::XML::Document)
1307
+ if parent.is_a?(Backend.document_class)
864
1308
  raise(
865
1309
  DOMException::NoModificationAllowedError,
866
1310
  "outerHTML setter not allowed on the document element"
@@ -878,15 +1322,15 @@ module Dommy
878
1322
  new_nodes.each { |n| parent.add_child(n) }
879
1323
  end
880
1324
 
881
- @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)
882
1326
  end
883
1327
 
884
1328
  # `el.contains(other)` — true if `other` is `el` itself or any
885
1329
  # descendant. Per spec, returns false for null/non-Node.
886
1330
  def contains?(other)
887
- return false unless other.respond_to?(:__node__)
1331
+ return false unless other.respond_to?(:__dommy_backend_node__)
888
1332
 
889
- other_node = other.__node__
1333
+ other_node = other.__dommy_backend_node__
890
1334
  return true if other_node == @__node__
891
1335
 
892
1336
  Internal::NodeTraversal.ancestor_of?(@__node__, other_node)
@@ -897,7 +1341,7 @@ module Dommy
897
1341
  # inside a shadow tree, returns that ShadowRoot. Otherwise walks
898
1342
  # until we hit the Nokogiri Document (then returns the Document).
899
1343
  def root_node
900
- sr = @document.__shadow_root_containing__(@__node__)
1344
+ sr = @document.__internal_shadow_root_containing__(@__node__)
901
1345
  return sr if sr
902
1346
 
903
1347
  current = @__node__
@@ -905,7 +1349,7 @@ module Dommy
905
1349
  loop do
906
1350
  parent = current.respond_to?(:parent) ? current.parent : nil
907
1351
  break unless parent
908
- if parent.is_a?(Nokogiri::XML::Document)
1352
+ if parent.is_a?(Backend.document_class)
909
1353
  attached = true
910
1354
  break
911
1355
  end
@@ -938,6 +1382,8 @@ module Dommy
938
1382
  end
939
1383
 
940
1384
  def toggle_attribute(name, force = nil)
1385
+ raise DOMException::InvalidCharacterError, "empty attribute name" if name.to_s.empty?
1386
+
941
1387
  key = name.to_s.downcase
942
1388
  present = @__node__.key?(key)
943
1389
  desired = force.nil? ? !present : !!force
@@ -951,10 +1397,11 @@ module Dommy
951
1397
  end
952
1398
 
953
1399
  def matches?(selector)
954
- return false if selector.nil? || selector.to_s.empty?
1400
+ return false if selector.nil?
1401
+ Internal.validate_selector!(selector)
955
1402
 
956
1403
  # `:scope` pseudo — match against this element itself.
957
- 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)")
958
1405
  matches_selector?(@__node__, sel)
959
1406
  end
960
1407
 
@@ -981,6 +1428,10 @@ module Dommy
981
1428
  end
982
1429
  end
983
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
+
984
1435
  # NamedNodeMap of attributes. Lazily allocated and re-used so
985
1436
  # `el.attributes === el.attributes` and `attr.ownerElement === el`.
986
1437
  def attributes
@@ -1003,11 +1454,15 @@ module Dommy
1003
1454
 
1004
1455
  # HTML namespace constants — most HTML elements live in xhtml ns.
1005
1456
  def namespace_uri
1006
- ns = @__node__.namespace
1007
- 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
1008
1461
  end
1009
1462
 
1010
1463
  def local_name
1464
+ return @__ns_local if @__ns_qname
1465
+
1011
1466
  @__node__.name.downcase
1012
1467
  end
1013
1468
 
@@ -1054,14 +1509,14 @@ module Dommy
1054
1509
 
1055
1510
  parent = current.respond_to?(:parent) ? current.parent : nil
1056
1511
  return false unless parent
1057
- return true if parent.is_a?(Nokogiri::XML::Document)
1512
+ return true if parent.is_a?(Backend.document_class)
1058
1513
 
1059
- sr = @document.__shadow_root_for_fragment__(parent)
1514
+ sr = @document.__internal_shadow_root_for_fragment__(parent)
1060
1515
  if sr
1061
1516
  host = sr.host
1062
1517
  return false unless host
1063
1518
 
1064
- current = host.__node__
1519
+ current = host.__dommy_backend_node__
1065
1520
  else
1066
1521
  current = parent
1067
1522
  end
@@ -1074,12 +1529,12 @@ module Dommy
1074
1529
  # tests rely on `document.activeElement` updating. Track the most
1075
1530
  # recently focused element on the document.
1076
1531
  def focus
1077
- @document.__set_active_element__(self)
1532
+ @document.__internal_set_active_element__(self)
1078
1533
  nil
1079
1534
  end
1080
1535
 
1081
1536
  def blur
1082
- @document.__set_active_element__(nil)
1537
+ @document.__internal_set_active_element__(nil)
1083
1538
  nil
1084
1539
  end
1085
1540
 
@@ -1149,7 +1604,7 @@ module Dommy
1149
1604
 
1150
1605
  # Internal — gives access to the shadow root regardless of mode.
1151
1606
  # Used by event composition / `composedPath()`.
1152
- def __shadow_root__
1607
+ def __internal_shadow_root__
1153
1608
  @__shadow_root
1154
1609
  end
1155
1610
 
@@ -1157,7 +1612,7 @@ module Dommy
1157
1612
  # "beforebegin", "afterbegin", "beforeend", "afterend". Returns the
1158
1613
  # inserted element or nil if position has no anchor (root cases).
1159
1614
  def insert_adjacent_element(position, element)
1160
- return nil unless element.respond_to?(:__node__)
1615
+ return nil unless element.respond_to?(:__dommy_backend_node__)
1161
1616
 
1162
1617
  case position.to_s
1163
1618
  when "beforebegin"
@@ -1165,22 +1620,22 @@ module Dommy
1165
1620
 
1166
1621
  node = detach_for_insert(element)
1167
1622
  @__node__.add_previous_sibling(node)
1168
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1623
+ notify_child_list(added: [node], target: @__node__.parent)
1169
1624
  when "afterbegin"
1170
1625
  node = detach_for_insert(element)
1171
1626
  first = @__node__.children.first
1172
1627
  first ? first.add_previous_sibling(node) : @__node__.add_child(node)
1173
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1628
+ notify_child_list(added: [node])
1174
1629
  when "beforeend"
1175
1630
  node = detach_for_insert(element)
1176
1631
  @__node__.add_child(node)
1177
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1632
+ notify_child_list(added: [node])
1178
1633
  when "afterend"
1179
1634
  return nil unless @__node__.parent
1180
1635
 
1181
1636
  node = detach_for_insert(element)
1182
1637
  @__node__.add_next_sibling(node)
1183
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1638
+ notify_child_list(added: [node], target: @__node__.parent)
1184
1639
  else
1185
1640
  return nil
1186
1641
  end
@@ -1189,36 +1644,56 @@ module Dommy
1189
1644
  end
1190
1645
 
1191
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
+
1192
1653
  fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
1193
1654
  nodes = fragment.children.to_a
1194
- 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
1195
1659
  when "beforebegin"
1196
- return nil unless @__node__.parent
1197
-
1198
- nodes.reverse_each { |n| @__node__.add_previous_sibling(n) }
1199
- @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)
1200
1663
  when "afterbegin"
1201
1664
  first = @__node__.children.first
1202
1665
  if first
1203
- nodes.reverse_each { |n| first.add_previous_sibling(n) }
1666
+ nodes.each { |n| first.add_previous_sibling(n) }
1204
1667
  else
1205
1668
  nodes.each { |n| @__node__.add_child(n) }
1206
1669
  end
1207
1670
 
1208
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1671
+ notify_child_list(added: nodes)
1209
1672
  when "beforeend"
1210
1673
  nodes.each { |n| @__node__.add_child(n) }
1211
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1674
+ notify_child_list(added: nodes)
1212
1675
  when "afterend"
1213
- return nil unless @__node__.parent
1214
-
1676
+ parent = insertion_parent!
1215
1677
  nodes.reverse_each { |n| @__node__.add_next_sibling(n) }
1216
- @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: nodes, removed_nodes: [])
1678
+ notify_child_list(added: nodes, target: parent)
1217
1679
  end
1218
1680
 
1219
1681
  nil
1220
1682
  end
1221
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
+
1222
1697
  def insert_adjacent_text(position, text)
1223
1698
  return nil if text.to_s.empty?
1224
1699
 
@@ -1254,48 +1729,7 @@ module Dommy
1254
1729
  # CONTAINS/CONTAINED_BY bitmask for ancestor/descendant pairs, or
1255
1730
  # PRECEDING/FOLLOWING for siblings (and DISCONNECTED for unrelated
1256
1731
  # nodes).
1257
- def compare_document_position(other)
1258
- return 0 if equal?(other)
1259
- return DOCUMENT_POSITION_DISCONNECTED unless other.respond_to?(:__node__)
1260
-
1261
- self_node = @__node__
1262
- other_node = other.__node__
1263
-
1264
- self_ancestors = ancestor_chain(self_node)
1265
- other_ancestors = ancestor_chain(other_node)
1266
-
1267
- common = nil
1268
- self_ancestors.each do |a|
1269
- if other_ancestors.include?(a)
1270
- common = a
1271
- break
1272
- end
1273
- end
1274
-
1275
- unless common
1276
- return DOCUMENT_POSITION_DISCONNECTED | DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC | DOCUMENT_POSITION_PRECEDING
1277
- end
1278
-
1279
- if common == self_node
1280
- return DOCUMENT_POSITION_CONTAINED_BY | DOCUMENT_POSITION_FOLLOWING
1281
- elsif common == other_node
1282
- return DOCUMENT_POSITION_CONTAINS | DOCUMENT_POSITION_PRECEDING
1283
- end
1284
-
1285
- # Sibling-of-some-level case: compare the two branch points
1286
- # under the common ancestor.
1287
- self_branch = branch_under(common, self_ancestors)
1288
- other_branch = branch_under(common, other_ancestors)
1289
- common.children.each do |child|
1290
- if child == self_branch
1291
- return DOCUMENT_POSITION_FOLLOWING
1292
- elsif child == other_branch
1293
- return DOCUMENT_POSITION_PRECEDING
1294
- end
1295
- end
1296
-
1297
- DOCUMENT_POSITION_DISCONNECTED
1298
- end
1732
+ # compareDocumentPosition is provided generically by the Node module.
1299
1733
 
1300
1734
  # `Node.isSameNode(other)` — strict reference identity. The DOM
1301
1735
  # spec deprecates this in favor of `===`, but linkedom-style
@@ -1309,62 +1743,19 @@ module Dommy
1309
1743
  # suite and standard DOM Node.isEqualNode.
1310
1744
  def equal_node?(other)
1311
1745
  return false unless other.is_a?(Element)
1312
- return false unless @__node__.name == other.__node__.name
1746
+ return false unless @__node__.name == other.__dommy_backend_node__.name
1313
1747
  return false unless attribute_signature == other.send(:attribute_signature)
1314
- return false unless @__node__.children.size == other.__node__.children.size
1748
+ return false unless @__node__.children.size == other.__dommy_backend_node__.children.size
1315
1749
 
1316
- @__node__.children.zip(other.__node__.children).all? do |a, b|
1750
+ @__node__.children.zip(other.__dommy_backend_node__.children).all? do |a, b|
1317
1751
  wa = @document.wrap_node(a)
1318
1752
  wb = @document.wrap_node(b)
1319
1753
  wa.respond_to?(:equal_node?) ? wa.equal_node?(wb) : a.content == b.content
1320
1754
  end
1321
1755
  end
1322
1756
 
1323
- private
1324
-
1325
- def ancestor_chain(node)
1326
- chain = [node]
1327
- Internal::NodeTraversal.each_ancestor(node) { |n| chain << n }
1328
- chain
1329
- end
1330
-
1331
- def branch_under(common, chain)
1332
- # Walk back along `chain` to find the entry whose parent is `common`.
1333
- chain.each_with_index do |node, i|
1334
- return node if i.zero? && node == common
1335
- return node if node.respond_to?(:parent) && node.parent == common
1336
- end
1337
-
1338
- nil
1339
- end
1340
-
1341
- def attribute_signature
1342
- @__node__.attribute_nodes.map { |a| [a.name, a.value] }.sort
1343
- end
1344
-
1345
- public
1346
-
1347
1757
  def remove
1348
- __js_call__("remove", [])
1349
- end
1350
-
1351
- # ParentNode mixin methods — append / prepend / replaceChildren
1352
- # take a mix of Node and String args (strings become text nodes).
1353
-
1354
- def append(*args)
1355
- append_nodes(args)
1356
- end
1357
-
1358
- def prepend(*args)
1359
- prepend_nodes(args)
1360
- end
1361
-
1362
- def replace_children(*args)
1363
- removed = @__node__.children.to_a
1364
- removed.each(&:unlink)
1365
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1366
- nodes.each { |n| @__node__.add_child(n) }
1367
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: removed)
1758
+ @document.remove_node_with_notify(@__node__)
1368
1759
  nil
1369
1760
  end
1370
1761
 
@@ -1394,25 +1785,61 @@ module Dommy
1394
1785
  end
1395
1786
 
1396
1787
  def click
1397
- __js_call__("click", [])
1788
+ dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
1398
1789
  end
1399
1790
 
1400
- # Ruby block-style listener (in addition to the (type, callable,
1401
- # options) form inherited from EventTarget). Returns the resolved
1402
- # listener so callers can pass it back to remove_event_listener.
1403
- def on(type, &block)
1404
- add_event_listener(type, block)
1405
- block
1791
+ def get_attribute_names
1792
+ Backend.attribute_nodes(@__node__).map(&:name)
1406
1793
  end
1407
1794
 
1408
- # `el[:foo]` / `el[:foo] = ...` bracket shortcut for the JS-style
1409
- # property access pattern. Useful when porting browser-side code
1410
- # to CRuby tests.
1411
- def [](key)
1412
- __js_get__(key.to_s)
1795
+ # No layout engine geometry getters return zeroed rects.
1796
+ def get_bounding_client_rect
1797
+ DOMRect.new
1413
1798
  end
1414
1799
 
1415
- def []=(key, value)
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
1825
+ end
1826
+
1827
+ # Ruby block-style listener (in addition to the (type, callable,
1828
+ # options) form inherited from EventTarget). Returns the resolved
1829
+ # listener so callers can pass it back to remove_event_listener.
1830
+ def on(type, &block)
1831
+ add_event_listener(type, block)
1832
+ block
1833
+ end
1834
+
1835
+ # `el[:foo]` / `el[:foo] = ...` bracket shortcut for the JS-style
1836
+ # property access pattern. Useful when porting browser-side code
1837
+ # to CRuby tests.
1838
+ def [](key)
1839
+ __js_get__(key.to_s)
1840
+ end
1841
+
1842
+ def []=(key, value)
1416
1843
  __js_set__(key.to_s, value)
1417
1844
  end
1418
1845
 
@@ -1444,8 +1871,26 @@ module Dommy
1444
1871
  get_attribute("popover")
1445
1872
  when "children"
1446
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
1447
1892
  when "firstElementChild"
1448
- @document.wrap_node(@__node__.element_children.first)
1893
+ first_element_child
1449
1894
  when "parentElement", "parent"
1450
1895
  wrap_parent(@__node__.parent)
1451
1896
  when "parentNode"
@@ -1456,16 +1901,23 @@ module Dommy
1456
1901
  when "textContent"
1457
1902
  @__node__.text
1458
1903
  when "innerHTML"
1459
- if @__node__.name == "template"
1460
- @document.template_content_inner_html(self)
1461
- else
1462
- @__node__.inner_html
1463
- end
1464
-
1904
+ inner_html
1905
+ when "outerHTML"
1906
+ outer_html
1465
1907
  when "tagName"
1466
- @__node__.name.upcase
1908
+ tag_name
1909
+ when "prefix"
1910
+ element_prefix
1467
1911
  when "classList"
1468
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")
1469
1921
  when "style"
1470
1922
  @style
1471
1923
  when "dataset"
@@ -1497,11 +1949,13 @@ module Dommy
1497
1949
  when "localName"
1498
1950
  local_name
1499
1951
  when "nodeName"
1500
- @__node__.name.upcase
1952
+ tag_name
1501
1953
  when "slot"
1502
1954
  slot
1503
1955
  when "role"
1504
- role
1956
+ aria_get("role")
1957
+ when "accessKeyLabel"
1958
+ access_key_label
1505
1959
  when "baseURI"
1506
1960
  base_uri
1507
1961
  when "shadowRoot"
@@ -1509,9 +1963,20 @@ module Dommy
1509
1963
  when "ownerDocument"
1510
1964
  @document
1511
1965
  else
1512
- # `el.onXxx` event handler property — returns the registered
1513
- # callback (if any), or nil.
1514
- 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.
1515
1980
  @on_handlers&.[](event_name_from_on(key))
1516
1981
  end
1517
1982
  end
@@ -1531,6 +1996,143 @@ module Dommy
1531
1996
  raw.to_s
1532
1997
  end
1533
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
+
1534
2136
  # Map a JS boolean property name to its underlying HTML attribute.
1535
2137
  # HTML attribute names are lowercase; the DOM property may be
1536
2138
  # camelCase (`readOnly` → `readonly`).
@@ -1541,25 +2143,12 @@ module Dommy
1541
2143
  def __js_set__(key, value)
1542
2144
  case key
1543
2145
  when "textContent"
1544
- @__node__.content = value.to_s
2146
+ self.text_content = value
1545
2147
  when "innerHTML"
1546
- removed = @__node__.children.to_a
1547
- if @__node__.name == "template"
1548
- # `<template>` content is invisible to outer selectors in
1549
- # real DOM (it lives in a separate DocumentFragment exposed
1550
- # via `[:content]`). Mirror that here so child placeholders
1551
- # inside the template don't pollute outer queries.
1552
- @document.attach_template_content(self, value.to_s)
1553
- else
1554
- @__node__.inner_html = value.to_s
1555
- @document.migrate_template_descendants(@__node__)
1556
- end
1557
-
1558
- @document.notify_child_list_mutation(
1559
- target_node: @__node__,
1560
- added_nodes: @__node__.children.to_a,
1561
- removed_nodes: removed
1562
- )
2148
+ self.inner_html = value
2149
+ when "outerHTML"
2150
+ # [CEReactions, LegacyNullToEmptyString] DOMString null becomes "".
2151
+ self.outer_html = value.nil? ? "" : value.to_s
1563
2152
  when "hidden", "disabled", "checked", "readOnly", "multiple", "required"
1564
2153
  # Boolean reflected property — funnel through set_attribute /
1565
2154
  # remove_attribute so MutationObserver attribute records fire.
@@ -1572,6 +2161,13 @@ module Dommy
1572
2161
 
1573
2162
  when "className"
1574
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)
1575
2171
  when "id"
1576
2172
  set_attribute("id", value.to_s)
1577
2173
  when "value"
@@ -1579,40 +2175,48 @@ module Dommy
1579
2175
  when "slot"
1580
2176
  set_attribute("slot", value.to_s)
1581
2177
  when "role"
1582
- set_attribute("role", value.to_s)
2178
+ aria_set("role", value)
1583
2179
  else
1584
- # `el.onXxx = fn` registers fn as a single named handler.
1585
- # Setting to nil removes it. Mirrors HTMLElement IDL.
1586
- 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.
1587
2191
  set_on_handler(event_name_from_on(key), value)
1588
2192
  else
1589
- 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
1590
2196
  end
1591
2197
  end
1592
2198
  end
1593
2199
 
1594
- private
1595
-
1596
- def event_name_from_on(key)
1597
- key.to_s.sub(/\Aon/, "").downcase
1598
- end
1599
-
1600
- def set_on_handler(event_name, value)
1601
- @on_handlers ||= {}
1602
- previous = @on_handlers[event_name]
1603
- remove_event_listener(event_name, previous) if previous
1604
- if value
1605
- add_event_listener(event_name, value)
1606
- @on_handlers[event_name] = value
1607
- else
1608
- @on_handlers.delete(event_name)
1609
- end
1610
- end
1611
-
1612
- public
1613
-
2200
+ include Bridge::Methods
2201
+ js_methods %w[
2202
+ getAttribute setAttribute hasAttribute removeAttribute getAttributeNames closest
2203
+ getAttributeNS setAttributeNS hasAttributeNS removeAttributeNS getAttributeNodeNS setAttributeNodeNS
2204
+ querySelector querySelectorAll getElementsByClassName getElementsByTagName getElementsByTagNameNS
2205
+ insertAdjacentElement insertAdjacentHTML insertAdjacentText toggleAttribute matches
2206
+ toString getAttributeNode setAttributeNode removeAttributeNode focus blur attachShadow
2207
+ addEventListener removeEventListener dispatchEvent appendChild insertBefore removeChild
2208
+ replaceChild cloneNode append prepend replaceChildren before after getInnerHTML getHTML
2209
+ remove replaceWith click getBoundingClientRect getClientRects scrollIntoView scroll
2210
+ scrollTo scrollBy requestFullscreen showPopover hidePopover togglePopover isEqualNode
2211
+ hasChildNodes hasAttributes getRootNode normalize contains
2212
+ compareDocumentPosition isSameNode lookupNamespaceURI lookupPrefix isDefaultNamespace
2213
+ ]
1614
2214
  def __js_call__(method, args)
1615
2215
  case method
2216
+ when "hasChildNodes"
2217
+ has_child_nodes?
2218
+ when "hasAttributes"
2219
+ has_attributes?
1616
2220
  when "getAttribute"
1617
2221
  get_attribute(args[0])
1618
2222
  when "setAttribute"
@@ -1621,18 +2225,38 @@ module Dommy
1621
2225
  has_attribute?(args[0])
1622
2226
  when "removeAttribute"
1623
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])
1624
2240
  when "getAttributeNames"
1625
- @__node__.attribute_nodes.map(&:name)
2241
+ get_attribute_names
1626
2242
  when "closest"
2243
+ raise Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
2244
+
1627
2245
  closest(args[0])
1628
2246
  when "querySelector"
1629
- query_selector(args[0])
2247
+ query_selector(Internal.css_query_arg!(args))
1630
2248
  when "querySelectorAll"
1631
- query_selector_all(args[0])
2249
+ query_selector_all(Internal.css_query_arg!(args))
1632
2250
  when "getElementsByClassName"
1633
2251
  get_elements_by_class_name(args[0])
2252
+ when "getElementsByTagNameNS"
2253
+ get_elements_by_tag_name_ns(args[0], args[1])
1634
2254
  when "getElementsByTagName"
1635
2255
  get_elements_by_tag_name(args[0])
2256
+ when "getRootNode"
2257
+ get_root_node
2258
+ when "normalize"
2259
+ normalize
1636
2260
  when "insertAdjacentElement"
1637
2261
  insert_adjacent_element(args[0], args[1])
1638
2262
  when "insertAdjacentHTML"
@@ -1642,7 +2266,23 @@ module Dommy
1642
2266
  when "toggleAttribute"
1643
2267
  toggle_attribute(args[0], args[1])
1644
2268
  when "matches"
2269
+ raise Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
2270
+
1645
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])
1646
2286
  when "toString"
1647
2287
  to_s
1648
2288
  when "getAttributeNode"
@@ -1660,7 +2300,7 @@ module Dommy
1660
2300
  when "addEventListener"
1661
2301
  add_event_listener(args[0], args[1], args[2])
1662
2302
  when "removeEventListener"
1663
- remove_event_listener(args[0], args[1])
2303
+ remove_event_listener(args[0], args[1], args[2])
1664
2304
  when "dispatchEvent"
1665
2305
  dispatch_event(args[0])
1666
2306
  when "appendChild"
@@ -1674,9 +2314,9 @@ module Dommy
1674
2314
  when "cloneNode"
1675
2315
  clone_node(args[0])
1676
2316
  when "append"
1677
- append_nodes(args)
2317
+ append(*args)
1678
2318
  when "prepend"
1679
- prepend_nodes(args)
2319
+ prepend(*args)
1680
2320
  when "replaceChildren"
1681
2321
  replace_children(*args)
1682
2322
  when "before"
@@ -1686,91 +2326,30 @@ module Dommy
1686
2326
  when "getInnerHTML", "getHTML"
1687
2327
  inner_html
1688
2328
  when "remove"
1689
- parent = @__node__.parent
1690
- @__node__.unlink
1691
- @document.notify_child_list_mutation(target_node: parent, added_nodes: [], removed_nodes: [@__node__]) if parent
1692
- nil
2329
+ remove
1693
2330
  when "replaceWith"
1694
2331
  replace_with(args)
1695
2332
  when "click"
1696
- dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
2333
+ click
1697
2334
  when "getBoundingClientRect"
1698
- DOMRect.new
2335
+ get_bounding_client_rect
1699
2336
  when "getClientRects"
1700
- []
2337
+ get_client_rects
1701
2338
  when "scrollIntoView", "scroll", "scrollTo", "scrollBy"
1702
- # No layout — record the request for tests to assert against.
1703
- @__scroll_log__ ||= []
1704
- @__scroll_log__ << [method, args]
1705
- nil
2339
+ record_scroll(method, args)
1706
2340
  when "requestFullscreen"
1707
- @document.__set_fullscreen_element__(self)
1708
- PromiseValue.resolve(@document.default_view, nil)
2341
+ request_fullscreen
1709
2342
  when "showPopover"
1710
- toggle_popover_state(true)
1711
- nil
2343
+ show_popover
1712
2344
  when "hidePopover"
1713
- toggle_popover_state(false)
1714
- nil
2345
+ hide_popover
1715
2346
  when "togglePopover"
1716
- new_state = !@__popover_open__
1717
- toggle_popover_state(new_state)
1718
- new_state
2347
+ toggle_popover
1719
2348
  else
1720
2349
  nil
1721
2350
  end
1722
2351
  end
1723
2352
 
1724
- private
1725
-
1726
- def normalize_attr_key(name)
1727
- s = name.to_s
1728
- case_sensitive_attribute_names? ? s : s.downcase
1729
- end
1730
-
1731
- def element_children
1732
- @__node__.element_children.each_with_object([]) do |node, out|
1733
- wrapped = @document.wrap_node(node)
1734
- out << wrapped if wrapped
1735
- end
1736
- end
1737
-
1738
- def wrap_parent(node)
1739
- @document.wrap_node(node)
1740
- end
1741
-
1742
- def __event_parent__
1743
- parent_node = @__node__.parent
1744
- # If our Nokogiri parent is a shadow tree's backing fragment,
1745
- # the bubble path's next stop is the ShadowRoot itself — not
1746
- # the bare Fragment wrapper. The ShadowRoot's __event_parent__
1747
- # will return nil (composed events route to host explicitly).
1748
- if parent_node.is_a?(Nokogiri::XML::DocumentFragment)
1749
- sr = @document.__shadow_root_for_fragment__(parent_node)
1750
- return sr if sr
1751
- end
1752
-
1753
- parent = wrap_parent(parent_node)
1754
- parent || @document
1755
- end
1756
-
1757
- def template_content
1758
- return nil unless @__node__.name == "template"
1759
-
1760
- @document.template_content_fragment(self)
1761
- end
1762
-
1763
- # Attribute name handling depends on the element's namespace:
1764
- # - HTML: case-insensitive (browser DOM stores everything lowercase).
1765
- # - SVG / other XML: case-sensitive (`viewBox` ≠ `viewbox`).
1766
- # Subclasses with a known namespace override `case_sensitive_attribute_names?`
1767
- # to flip the behavior. Generic Element nodes inspect the namespace
1768
- # URI directly.
1769
- def case_sensitive_attribute_names?
1770
- ns = namespace_uri
1771
- !ns.nil? && ns != "http://www.w3.org/1999/xhtml"
1772
- end
1773
-
1774
2353
  def get_attribute(name)
1775
2354
  return nil if name.nil?
1776
2355
 
@@ -1780,9 +2359,17 @@ module Dommy
1780
2359
  def set_attribute(name, value)
1781
2360
  return nil if name.nil?
1782
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
+
1783
2367
  key = normalize_attr_key(name)
1784
2368
  old = @__node__[key]
1785
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-")
1786
2373
  @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1787
2374
  nil
1788
2375
  end
@@ -1800,17 +2387,75 @@ module Dommy
1800
2387
  return nil unless @__node__.key?(key)
1801
2388
 
1802
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)
1803
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-")
1804
2397
  @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1805
2398
  nil
1806
2399
  end
1807
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
+
1808
2444
  def closest(selector)
1809
- 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
1810
2455
 
1811
2456
  node = @__node__
1812
2457
  while node&.element?
1813
- return @document.wrap_node(node) if matches_selector?(node, selector.to_s)
2458
+ return @document.wrap_node(node) if matched.include?(node.pointer_id)
1814
2459
 
1815
2460
  node = node.parent
1816
2461
  end
@@ -1818,6 +2463,23 @@ module Dommy
1818
2463
  nil
1819
2464
  end
1820
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
+
1821
2483
  # Web Animations: start an animation on this element.
1822
2484
  # Returns the new Animation. Dommy doesn't interpolate; the
1823
2485
  # animation simply transitions through the `playState` lifecycle,
@@ -1839,23 +2501,54 @@ module Dommy
1839
2501
  alias getAnimations get_animations
1840
2502
 
1841
2503
  def query_selector(selector)
1842
- 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)
1843
2508
 
1844
- @document.wrap_node(@__node__.at_css(selector.to_s))
2509
+ @document.wrap_node(scoped_query(selector.to_s).first)
1845
2510
  end
1846
2511
 
1847
2512
  def query_selector_all(selector)
1848
- return NodeList.new if selector.nil? || selector.to_s.empty?
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
2537
+ end
2538
+
2539
+ # XPath queries scoped to this element, returning wrapped nodes.
2540
+ def at_xpath(expression)
2541
+ node = @__node__.at_xpath(expression)
2542
+ node && @document.wrap_node(node)
2543
+ end
1849
2544
 
1850
- NodeList.new(@__node__.css(selector.to_s).map { |node| @document.wrap_node(node) }.compact)
2545
+ def xpath(expression)
2546
+ @__node__.xpath(expression).map { |node| @document.wrap_node(node) }
1851
2547
  end
1852
2548
 
1853
- def append_child(child)
1854
- check_hierarchy!(child)
1855
- nodes = detach_dom_nodes(child)
1856
- append_dom_nodes(nodes)
1857
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1858
- child
2549
+ # The XPath string locating this element in its document.
2550
+ def path
2551
+ @__node__.path
1859
2552
  end
1860
2553
 
1861
2554
  def insert_before(child, reference)
@@ -1875,7 +2568,7 @@ module Dommy
1875
2568
  end
1876
2569
  end
1877
2570
 
1878
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
2571
+ notify_child_list(added: nodes)
1879
2572
  child
1880
2573
  end
1881
2574
 
@@ -1885,8 +2578,7 @@ module Dommy
1885
2578
  raise DOMException::NotFoundError, "node is not a child of this element"
1886
2579
  end
1887
2580
 
1888
- node.unlink
1889
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [], removed_nodes: [node])
2581
+ @document.remove_node_with_notify(node)
1890
2582
  child
1891
2583
  end
1892
2584
 
@@ -1902,50 +2594,102 @@ module Dommy
1902
2594
  new_nodes = detach_dom_nodes(new_child)
1903
2595
  new_nodes.reverse_each { |node| old_node.add_previous_sibling(node) }
1904
2596
  old_node.unlink
1905
- @document.notify_child_list_mutation(
1906
- target_node: @__node__,
1907
- added_nodes: new_nodes,
1908
- removed_nodes: [old_node]
1909
- )
2597
+ notify_child_list(added: new_nodes, removed: [old_node])
1910
2598
  old_child
1911
2599
  end
1912
2600
 
1913
2601
  def clone_node(deep_arg)
1914
- deep = !!deep_arg
1915
- if deep
1916
- @document.wrap_node(
1917
- Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc).children.find(&:element?)
1918
- )
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
1919
2638
  else
1920
- clone = @document.create_element(@__node__.name)
1921
- @__node__.attribute_nodes.each do |attr|
1922
- clone.__js_call__("setAttribute", [attr.name, attr.value])
1923
- end
2639
+ @on_handlers.delete(event_name)
2640
+ end
2641
+ end
2642
+
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
1924
2648
 
1925
- clone
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
1926
2653
  end
1927
2654
  end
1928
2655
 
1929
- def append_nodes(args)
1930
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1931
- append_dom_nodes(nodes)
1932
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1933
- nil
2656
+ def wrap_parent(node)
2657
+ @document.wrap_node(node)
1934
2658
  end
1935
2659
 
1936
- def prepend_nodes(args)
1937
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1938
- anchor = @__node__.children.first
1939
- if anchor
1940
- nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
1941
- else
1942
- 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
1943
2669
  end
1944
2670
 
1945
- @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1946
- nil
2671
+ parent = wrap_parent(parent_node)
2672
+ parent || @document
2673
+ end
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"
1947
2690
  end
1948
2691
 
2692
+ # Insertion / scroll / popover helpers.
1949
2693
  def insert_adjacent(side, args)
1950
2694
  parent = @__node__.parent
1951
2695
  return nil unless parent
@@ -1963,7 +2707,7 @@ module Dommy
1963
2707
  end
1964
2708
  end
1965
2709
 
1966
- @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [])
2710
+ notify_child_list(added: nodes, target: parent)
1967
2711
  nil
1968
2712
  end
1969
2713
 
@@ -1981,7 +2725,7 @@ module Dommy
1981
2725
  nodes.each { |node| parent.add_child(node) }
1982
2726
  end
1983
2727
 
1984
- @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [removed])
2728
+ notify_child_list(added: nodes, removed: [removed], target: parent)
1985
2729
  nil
1986
2730
  end
1987
2731
 
@@ -1989,14 +2733,20 @@ module Dommy
1989
2733
  nodes.each { |node| @__node__.add_child(node) }
1990
2734
  end
1991
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
+
1992
2742
  # Raise HierarchyRequestError when the proposed insertion would
1993
2743
  # produce a cycle (inserting an ancestor as a descendant of
1994
2744
  # itself). Strings and Fragments are always safe.
1995
2745
  def check_hierarchy!(child)
1996
- return unless child.respond_to?(:__node__)
2746
+ return unless child.respond_to?(:__dommy_backend_node__)
1997
2747
 
1998
- node = child.__node__
1999
- return unless node.is_a?(Nokogiri::XML::Node)
2748
+ node = child.__dommy_backend_node__
2749
+ return unless node.is_a?(Backend.node_class)
2000
2750
 
2001
2751
  if node == @__node__ || @__node__.ancestors.any? { |a| a == node }
2002
2752
  raise(
@@ -2010,27 +2760,8 @@ module Dommy
2010
2760
  detach_dom_nodes(value).first
2011
2761
  end
2012
2762
 
2013
- def detach_dom_nodes(value)
2014
- case value
2015
- when Element, TextNode, CommentNode
2016
- node = value.__node__
2017
- node.unlink if node.parent
2018
- [node]
2019
- when Fragment
2020
- value.extract_children
2021
- when String
2022
- [@document.create_text_node(value).__node__]
2023
- else
2024
- node = unwrap_dom_node(value)
2025
- return [] unless node
2026
-
2027
- node.unlink if node.parent
2028
- [node]
2029
- end
2030
- end
2031
-
2032
2763
  def unwrap_dom_node(value)
2033
- return value.__node__ if value.respond_to?(:__node__)
2764
+ return value.__dommy_backend_node__ if value.respond_to?(:__dommy_backend_node__)
2034
2765
 
2035
2766
  nil
2036
2767
  end
@@ -2043,6 +2774,13 @@ module Dommy
2043
2774
  end
2044
2775
  end
2045
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
+
2046
2784
  # Popover state — modern HTML pattern. `show`/`hide`/`toggle`
2047
2785
  # fire `beforetoggle` and `toggle` events (no real visual change).
2048
2786
  def toggle_popover_state(open)
@@ -2064,34 +2802,5 @@ module Dommy
2064
2802
  )
2065
2803
  )
2066
2804
  end
2067
-
2068
- # Test inspector for scroll calls (no real layout to scroll).
2069
- def __scroll_log__
2070
- @__scroll_log__ ||= []
2071
- end
2072
-
2073
- public :__scroll_log__
2074
-
2075
- # Re-expose snake_case methods that the JS bridge dispatch routes
2076
- # to. Defined as private originally so internal helpers (element_children,
2077
- # detach_dom_nodes, etc.) stay encapsulated; CRuby users call these
2078
- # as the public Ruby API.
2079
- public(
2080
- :get_attribute,
2081
- :set_attribute,
2082
- :has_attribute?,
2083
- :remove_attribute,
2084
- :append_child,
2085
- :insert_before,
2086
- :remove_child,
2087
- :replace_child,
2088
- :clone_node,
2089
- :query_selector,
2090
- :query_selector_all,
2091
- :closest,
2092
- :animate,
2093
- :get_animations,
2094
- :getAnimations
2095
- )
2096
2805
  end
2097
2806
  end