dommy 0.5.0 → 0.7.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -13
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +147 -0
  10. data/lib/dommy/cookie_store.rb +128 -0
  11. data/lib/dommy/crypto.rb +396 -0
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +190 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +356 -53
  17. data/lib/dommy/event.rb +431 -25
  18. data/lib/dommy/event_source.rb +131 -0
  19. data/lib/dommy/fetch.rb +76 -6
  20. data/lib/dommy/file_reader.rb +176 -0
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +82 -0
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +130 -67
  25. data/lib/dommy/internal/cookie_jar.rb +2 -0
  26. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  27. data/lib/dommy/internal/dom_matching.rb +4 -4
  28. data/lib/dommy/internal/idna.rb +443 -0
  29. data/lib/dommy/internal/idna_data.rb +10379 -0
  30. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  31. data/lib/dommy/internal/node_traversal.rb +1 -1
  32. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  33. data/lib/dommy/internal/observable_callback.rb +25 -0
  34. data/lib/dommy/internal/punycode.rb +202 -0
  35. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  36. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  37. data/lib/dommy/internal/template_content_registry.rb +6 -6
  38. data/lib/dommy/intersection_observer.rb +82 -0
  39. data/lib/dommy/{router.rb → location.rb} +8 -142
  40. data/lib/dommy/media_query_list.rb +118 -0
  41. data/lib/dommy/message_channel.rb +249 -0
  42. data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
  43. data/lib/dommy/navigator.rb +365 -5
  44. data/lib/dommy/node.rb +12 -0
  45. data/lib/dommy/notification.rb +89 -0
  46. data/lib/dommy/parser.rb +13 -13
  47. data/lib/dommy/performance.rb +146 -0
  48. data/lib/dommy/performance_observer.rb +55 -0
  49. data/lib/dommy/range.rb +597 -0
  50. data/lib/dommy/resize_observer.rb +53 -0
  51. data/lib/dommy/shadow_root.rb +10 -8
  52. data/lib/dommy/streams.rb +386 -0
  53. data/lib/dommy/svg_elements.rb +3863 -0
  54. data/lib/dommy/text_codec.rb +175 -0
  55. data/lib/dommy/tree_walker.rb +21 -21
  56. data/lib/dommy/url.rb +274 -29
  57. data/lib/dommy/url_pattern.rb +144 -0
  58. data/lib/dommy/version.rb +1 -1
  59. data/lib/dommy/web_socket.rb +209 -0
  60. data/lib/dommy/window.rb +369 -0
  61. data/lib/dommy/worker.rb +143 -0
  62. data/lib/dommy/xml_http_request.rb +438 -0
  63. data/lib/dommy.rb +43 -5
  64. metadata +44 -29
  65. data/lib/dommy/world.rb +0 -209
data/lib/dommy/element.rb CHANGED
@@ -9,7 +9,9 @@ module Dommy
9
9
  include EventTarget
10
10
  include Node
11
11
 
12
- attr_reader :__node__, :document
12
+ attr_reader :document
13
+
14
+ def __dommy_backend_node__ = @__node__
13
15
 
14
16
  def initialize(document, nokogiri_node)
15
17
  @document = document
@@ -98,6 +100,12 @@ module Dommy
98
100
  end
99
101
  end
100
102
 
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
+
101
109
  def __js_call__(method, args)
102
110
  case method
103
111
  when "cloneNode"
@@ -128,9 +136,9 @@ module Dommy
128
136
  def detach_dom_nodes(value)
129
137
  case value
130
138
  when String
131
- [@document.create_text_node(value).__node__]
139
+ [@document.create_text_node(value).__dommy_backend_node__]
132
140
  else
133
- node = value.respond_to?(:__node__) ? value.__node__ : nil
141
+ node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
134
142
  return [] unless node
135
143
 
136
144
  node.unlink if node.parent
@@ -147,7 +155,7 @@ module Dommy
147
155
 
148
156
  # Fragments aren't part of the bubble chain; nil terminates
149
157
  # bubbling at the boundary (shadow root, detached fragment, etc.).
150
- def __event_parent__
158
+ def __internal_event_parent__
151
159
  nil
152
160
  end
153
161
  end
@@ -157,7 +165,7 @@ module Dommy
157
165
  class CharacterDataNode
158
166
  include Node
159
167
 
160
- attr_reader :__node__
168
+ def __dommy_backend_node__ = @__node__
161
169
 
162
170
  def initialize(document, nokogiri_node)
163
171
  @document = document
@@ -191,7 +199,19 @@ module Dommy
191
199
  end
192
200
 
193
201
  def remove
202
+ parent = @__node__.parent
203
+ removed = @__node__
194
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
195
215
  nil
196
216
  end
197
217
 
@@ -243,16 +263,105 @@ module Dommy
243
263
  nil
244
264
  end
245
265
 
246
- def __js_call__(method, _args)
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
+
272
+ def __js_call__(method, args)
247
273
  case method
248
274
  when "remove"
249
- @__node__.unlink
250
- nil
275
+ remove
276
+ when "before"
277
+ before(*args)
278
+ when "after"
279
+ after(*args)
280
+ when "replaceWith"
281
+ replace_with(*args)
251
282
  end
252
283
  end
253
284
 
285
+ # ChildNode mixin: WHATWG DOM defines `before`, `after`,
286
+ # `replaceWith` on all child nodes, including Text and Comment.
287
+ # Implementations operate on the Nokogiri layer and notify the
288
+ # MutationObserver with the underlying nodes (mirroring
289
+ # Element#remove_child / replace_child).
290
+
291
+ def before(*args)
292
+ parent = @__node__.parent
293
+ return nil unless parent
294
+
295
+ added = args.map { |arg| coerce_node(arg) }.compact
296
+ added.reverse_each { |node| @__node__.add_previous_sibling(node) }
297
+ notify_child_list_added(parent, added)
298
+ nil
299
+ end
300
+
301
+ def after(*args)
302
+ parent = @__node__.parent
303
+ return nil unless parent
304
+
305
+ added = args.map { |arg| coerce_node(arg) }.compact
306
+ anchor = @__node__.next_sibling
307
+ if anchor
308
+ added.reverse_each { |node| anchor.add_previous_sibling(node) }
309
+ else
310
+ added.each { |node| parent.add_child(node) }
311
+ end
312
+ notify_child_list_added(parent, added)
313
+ nil
314
+ end
315
+
316
+ def replace_with(*args)
317
+ parent = @__node__.parent
318
+ return nil unless parent
319
+
320
+ added = args.map { |arg| coerce_node(arg) }.compact
321
+ removed = @__node__
322
+ anchor = @__node__.next_sibling
323
+ @__node__.unlink
324
+ if anchor
325
+ added.reverse_each { |node| anchor.add_previous_sibling(node) }
326
+ else
327
+ added.each { |node| parent.add_child(node) }
328
+ end
329
+ @document.notify_child_list_mutation(
330
+ target_node: parent,
331
+ added_nodes: added,
332
+ removed_nodes: [removed]
333
+ )
334
+ nil
335
+ end
336
+
254
337
  private
255
338
 
339
+ # Coerce a `before` / `after` / `replaceWith` argument into a raw
340
+ # Nokogiri node, ready to be linked into a parent. Strings become
341
+ # fresh text nodes; existing nodes are detached from their current
342
+ # parent first (matching Element#detach_dom_nodes minus the
343
+ # Fragment branch which is rarely needed off a text/comment node).
344
+ def coerce_node(arg)
345
+ case arg
346
+ when String
347
+ @document.create_text_node(arg).__dommy_backend_node__
348
+ else
349
+ node = arg.respond_to?(:__dommy_backend_node__) ? arg.__dommy_backend_node__ : nil
350
+ node.unlink if node && node.parent
351
+ node
352
+ end
353
+ end
354
+
355
+ def notify_child_list_added(parent, added)
356
+ return if added.empty?
357
+
358
+ @document.notify_child_list_mutation(
359
+ target_node: parent,
360
+ added_nodes: added,
361
+ removed_nodes: []
362
+ )
363
+ end
364
+
256
365
  def write_data(value)
257
366
  old = @__node__.content
258
367
  @__node__.content = value.to_s
@@ -265,6 +374,12 @@ module Dommy
265
374
  3
266
375
  end
267
376
 
377
+ # 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
+
268
383
  def __js_call__(method, args)
269
384
  case method
270
385
  when "cloneNode"
@@ -280,6 +395,12 @@ module Dommy
280
395
  8
281
396
  end
282
397
 
398
+ # 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
+
283
404
  def __js_call__(method, args)
284
405
  case method
285
406
  when "cloneNode"
@@ -311,7 +432,7 @@ module Dommy
311
432
  end
312
433
 
313
434
  def value
314
- @element.__node__["class"].to_s
435
+ @element.__dommy_backend_node__["class"].to_s
315
436
  end
316
437
 
317
438
  def value=(new_value)
@@ -383,6 +504,12 @@ module Dommy
383
504
  nil
384
505
  end
385
506
 
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
+
386
513
  def __js_call__(method, args)
387
514
  case method
388
515
  when "add"
@@ -444,14 +571,14 @@ module Dommy
444
571
  end
445
572
 
446
573
  def class_tokens
447
- raw = @element.__node__["class"].to_s
574
+ raw = @element.__dommy_backend_node__["class"].to_s
448
575
  raw.split(/\s+/).reject(&:empty?)
449
576
  end
450
577
 
451
578
  def update_tokens
452
579
  tokens = yield(class_tokens)
453
580
  if tokens.empty?
454
- @element.remove_attribute("class") if @element.__node__.key?("class")
581
+ @element.remove_attribute("class") if @element.__dommy_backend_node__.key?("class")
455
582
  else
456
583
  @element.set_attribute("class", tokens.join(" "))
457
584
  end
@@ -467,7 +594,7 @@ module Dommy
467
594
  end
468
595
 
469
596
  def __js_get__(key)
470
- @element.__node__[attr_name(key)]
597
+ @element.__dommy_backend_node__[attr_name(key)]
471
598
  end
472
599
 
473
600
  def __js_set__(key, value)
@@ -491,6 +618,8 @@ module Dommy
491
618
  # positioning sees zeroed values; absolute layout assertions need
492
619
  # the real browser.
493
620
  class DOMRect
621
+ attr_reader :x, :y, :width, :height
622
+
494
623
  def initialize(x: 0, y: 0, width: 0, height: 0)
495
624
  @x = x
496
625
  @y = y
@@ -498,6 +627,22 @@ module Dommy
498
627
  @height = height
499
628
  end
500
629
 
630
+ def top
631
+ @y
632
+ end
633
+
634
+ def left
635
+ @x
636
+ end
637
+
638
+ def right
639
+ @x + @width
640
+ end
641
+
642
+ def bottom
643
+ @y + @height
644
+ end
645
+
501
646
  def __js_get__(key)
502
647
  case key
503
648
  when "x", "left"
@@ -611,6 +756,12 @@ module Dommy
611
756
  nil
612
757
  end
613
758
 
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
+
614
765
  def __js_call__(method, args)
615
766
  case method
616
767
  when "setProperty"
@@ -656,7 +807,7 @@ module Dommy
656
807
  end
657
808
 
658
809
  def properties
659
- raw = @element.__node__["style"].to_s
810
+ raw = @element.__dommy_backend_node__["style"].to_s
660
811
  raw.split(";").each_with_object({}) do |entry, out|
661
812
  key, value = entry.split(":", 2)
662
813
  next unless key && value
@@ -667,7 +818,7 @@ module Dommy
667
818
 
668
819
  def write_properties(props)
669
820
  if props.empty?
670
- @element.remove_attribute("style") if @element.__node__.key?("style")
821
+ @element.remove_attribute("style") if @element.__dommy_backend_node__.key?("style")
671
822
  else
672
823
  @element.set_attribute("style", props.map { |k, v| "#{k}:#{v}" }.join(";"))
673
824
  end
@@ -678,7 +829,9 @@ module Dommy
678
829
  include EventTarget
679
830
  include Node
680
831
 
681
- attr_reader :__node__, :document
832
+ attr_reader :document
833
+
834
+ def __dommy_backend_node__ = @__node__
682
835
 
683
836
  def initialize(document, nokogiri_node)
684
837
  @document = document
@@ -842,7 +995,7 @@ module Dommy
842
995
  parent = @__node__.parent
843
996
  return unless parent
844
997
 
845
- if parent.is_a?(Nokogiri::XML::Document)
998
+ if parent.is_a?(Backend.document_class)
846
999
  raise(
847
1000
  DOMException::NoModificationAllowedError,
848
1001
  "outerHTML setter not allowed on the document element"
@@ -866,9 +1019,9 @@ module Dommy
866
1019
  # `el.contains(other)` — true if `other` is `el` itself or any
867
1020
  # descendant. Per spec, returns false for null/non-Node.
868
1021
  def contains?(other)
869
- return false unless other.respond_to?(:__node__)
1022
+ return false unless other.respond_to?(:__dommy_backend_node__)
870
1023
 
871
- other_node = other.__node__
1024
+ other_node = other.__dommy_backend_node__
872
1025
  return true if other_node == @__node__
873
1026
 
874
1027
  Internal::NodeTraversal.ancestor_of?(@__node__, other_node)
@@ -879,7 +1032,7 @@ module Dommy
879
1032
  # inside a shadow tree, returns that ShadowRoot. Otherwise walks
880
1033
  # until we hit the Nokogiri Document (then returns the Document).
881
1034
  def root_node
882
- sr = @document.__shadow_root_containing__(@__node__)
1035
+ sr = @document.__internal_shadow_root_containing__(@__node__)
883
1036
  return sr if sr
884
1037
 
885
1038
  current = @__node__
@@ -887,7 +1040,7 @@ module Dommy
887
1040
  loop do
888
1041
  parent = current.respond_to?(:parent) ? current.parent : nil
889
1042
  break unless parent
890
- if parent.is_a?(Nokogiri::XML::Document)
1043
+ if parent.is_a?(Backend.document_class)
891
1044
  attached = true
892
1045
  break
893
1046
  end
@@ -1036,14 +1189,14 @@ module Dommy
1036
1189
 
1037
1190
  parent = current.respond_to?(:parent) ? current.parent : nil
1038
1191
  return false unless parent
1039
- return true if parent.is_a?(Nokogiri::XML::Document)
1192
+ return true if parent.is_a?(Backend.document_class)
1040
1193
 
1041
- sr = @document.__shadow_root_for_fragment__(parent)
1194
+ sr = @document.__internal_shadow_root_for_fragment__(parent)
1042
1195
  if sr
1043
1196
  host = sr.host
1044
1197
  return false unless host
1045
1198
 
1046
- current = host.__node__
1199
+ current = host.__dommy_backend_node__
1047
1200
  else
1048
1201
  current = parent
1049
1202
  end
@@ -1056,12 +1209,12 @@ module Dommy
1056
1209
  # tests rely on `document.activeElement` updating. Track the most
1057
1210
  # recently focused element on the document.
1058
1211
  def focus
1059
- @document.__set_active_element__(self)
1212
+ @document.__internal_set_active_element__(self)
1060
1213
  nil
1061
1214
  end
1062
1215
 
1063
1216
  def blur
1064
- @document.__set_active_element__(nil)
1217
+ @document.__internal_set_active_element__(nil)
1065
1218
  nil
1066
1219
  end
1067
1220
 
@@ -1131,7 +1284,7 @@ module Dommy
1131
1284
 
1132
1285
  # Internal — gives access to the shadow root regardless of mode.
1133
1286
  # Used by event composition / `composedPath()`.
1134
- def __shadow_root__
1287
+ def __internal_shadow_root__
1135
1288
  @__shadow_root
1136
1289
  end
1137
1290
 
@@ -1139,7 +1292,7 @@ module Dommy
1139
1292
  # "beforebegin", "afterbegin", "beforeend", "afterend". Returns the
1140
1293
  # inserted element or nil if position has no anchor (root cases).
1141
1294
  def insert_adjacent_element(position, element)
1142
- return nil unless element.respond_to?(:__node__)
1295
+ return nil unless element.respond_to?(:__dommy_backend_node__)
1143
1296
 
1144
1297
  case position.to_s
1145
1298
  when "beforebegin"
@@ -1238,10 +1391,10 @@ module Dommy
1238
1391
  # nodes).
1239
1392
  def compare_document_position(other)
1240
1393
  return 0 if equal?(other)
1241
- return DOCUMENT_POSITION_DISCONNECTED unless other.respond_to?(:__node__)
1394
+ return DOCUMENT_POSITION_DISCONNECTED unless other.respond_to?(:__dommy_backend_node__)
1242
1395
 
1243
1396
  self_node = @__node__
1244
- other_node = other.__node__
1397
+ other_node = other.__dommy_backend_node__
1245
1398
 
1246
1399
  self_ancestors = ancestor_chain(self_node)
1247
1400
  other_ancestors = ancestor_chain(other_node)
@@ -1291,11 +1444,11 @@ module Dommy
1291
1444
  # suite and standard DOM Node.isEqualNode.
1292
1445
  def equal_node?(other)
1293
1446
  return false unless other.is_a?(Element)
1294
- return false unless @__node__.name == other.__node__.name
1447
+ return false unless @__node__.name == other.__dommy_backend_node__.name
1295
1448
  return false unless attribute_signature == other.send(:attribute_signature)
1296
- return false unless @__node__.children.size == other.__node__.children.size
1449
+ return false unless @__node__.children.size == other.__dommy_backend_node__.children.size
1297
1450
 
1298
- @__node__.children.zip(other.__node__.children).all? do |a, b|
1451
+ @__node__.children.zip(other.__dommy_backend_node__.children).all? do |a, b|
1299
1452
  wa = @document.wrap_node(a)
1300
1453
  wb = @document.wrap_node(b)
1301
1454
  wa.respond_to?(:equal_node?) ? wa.equal_node?(wb) : a.content == b.content
@@ -1404,6 +1557,26 @@ module Dommy
1404
1557
  1
1405
1558
  when "isConnected"
1406
1559
  is_connected?
1560
+ when
1561
+ "scrollTop",
1562
+ "scrollLeft",
1563
+ "scrollWidth",
1564
+ "scrollHeight",
1565
+ "clientWidth",
1566
+ "clientHeight",
1567
+ "clientTop",
1568
+ "clientLeft",
1569
+ "offsetWidth",
1570
+ "offsetHeight",
1571
+ "offsetTop",
1572
+ "offsetLeft"
1573
+ # No layout engine — zeroed values match what real browsers
1574
+ # report for hidden / pre-paint elements.
1575
+ 0
1576
+ when "offsetParent"
1577
+ nil
1578
+ when "popover"
1579
+ get_attribute("popover")
1407
1580
  when "children"
1408
1581
  @live_children
1409
1582
  when "firstElementChild"
@@ -1503,7 +1676,20 @@ module Dommy
1503
1676
  def __js_set__(key, value)
1504
1677
  case key
1505
1678
  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
1506
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
1507
1693
  when "innerHTML"
1508
1694
  removed = @__node__.children.to_a
1509
1695
  if @__node__.name == "template"
@@ -1573,6 +1759,21 @@ module Dommy
1573
1759
 
1574
1760
  public
1575
1761
 
1762
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
1763
+ JS_METHOD_NAMES = %w[
1764
+ getAttribute setAttribute hasAttribute removeAttribute getAttributeNames closest
1765
+ querySelector querySelectorAll getElementsByClassName getElementsByTagName
1766
+ insertAdjacentElement insertAdjacentHTML insertAdjacentText toggleAttribute matches
1767
+ toString getAttributeNode setAttributeNode removeAttributeNode focus blur attachShadow
1768
+ addEventListener removeEventListener dispatchEvent appendChild insertBefore removeChild
1769
+ replaceChild cloneNode append prepend replaceChildren before after getInnerHTML getHTML
1770
+ 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
+
1576
1777
  def __js_call__(method, args)
1577
1778
  case method
1578
1779
  when "getAttribute"
@@ -1658,6 +1859,26 @@ module Dommy
1658
1859
  dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
1659
1860
  when "getBoundingClientRect"
1660
1861
  DOMRect.new
1862
+ when "getClientRects"
1863
+ []
1864
+ 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
1869
+ when "requestFullscreen"
1870
+ @document.__internal_set_fullscreen_element__(self)
1871
+ PromiseValue.resolve(@document.default_view, nil)
1872
+ when "showPopover"
1873
+ toggle_popover_state(true)
1874
+ nil
1875
+ when "hidePopover"
1876
+ toggle_popover_state(false)
1877
+ nil
1878
+ when "togglePopover"
1879
+ new_state = !@__popover_open__
1880
+ toggle_popover_state(new_state)
1881
+ new_state
1661
1882
  else
1662
1883
  nil
1663
1884
  end
@@ -1665,6 +1886,11 @@ module Dommy
1665
1886
 
1666
1887
  private
1667
1888
 
1889
+ def normalize_attr_key(name)
1890
+ s = name.to_s
1891
+ case_sensitive_attribute_names? ? s : s.downcase
1892
+ end
1893
+
1668
1894
  def element_children
1669
1895
  @__node__.element_children.each_with_object([]) do |node, out|
1670
1896
  wrapped = @document.wrap_node(node)
@@ -1676,14 +1902,14 @@ module Dommy
1676
1902
  @document.wrap_node(node)
1677
1903
  end
1678
1904
 
1679
- def __event_parent__
1905
+ def __internal_event_parent__
1680
1906
  parent_node = @__node__.parent
1681
1907
  # If our Nokogiri parent is a shadow tree's backing fragment,
1682
1908
  # the bubble path's next stop is the ShadowRoot itself — not
1683
- # the bare Fragment wrapper. The ShadowRoot's __event_parent__
1909
+ # the bare Fragment wrapper. The ShadowRoot's __internal_event_parent__
1684
1910
  # will return nil (composed events route to host explicitly).
1685
- if parent_node.is_a?(Nokogiri::XML::DocumentFragment)
1686
- sr = @document.__shadow_root_for_fragment__(parent_node)
1911
+ if parent_node.is_a?(Backend.document_fragment_class)
1912
+ sr = @document.__internal_shadow_root_for_fragment__(parent_node)
1687
1913
  return sr if sr
1688
1914
  end
1689
1915
 
@@ -1697,20 +1923,27 @@ module Dommy
1697
1923
  @document.template_content_fragment(self)
1698
1924
  end
1699
1925
 
1700
- # HTML attribute names are case-insensitive browser DOM stores
1701
- # them in lowercase regardless of the case passed to setAttribute.
1702
- # Matches that behavior so callers using `"SRC"` / `"Action"` /
1703
- # etc. interoperate with `getAttribute("src")` round-trips.
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
+
1704
1937
  def get_attribute(name)
1705
1938
  return nil if name.nil?
1706
1939
 
1707
- @__node__[name.to_s.downcase]
1940
+ @__node__[normalize_attr_key(name)]
1708
1941
  end
1709
1942
 
1710
1943
  def set_attribute(name, value)
1711
1944
  return nil if name.nil?
1712
1945
 
1713
- key = name.to_s.downcase
1946
+ key = normalize_attr_key(name)
1714
1947
  old = @__node__[key]
1715
1948
  @__node__[key] = value.to_s
1716
1949
  @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
@@ -1720,13 +1953,13 @@ module Dommy
1720
1953
  def has_attribute?(name)
1721
1954
  return false if name.nil?
1722
1955
 
1723
- @__node__.key?(name.to_s.downcase)
1956
+ @__node__.key?(normalize_attr_key(name))
1724
1957
  end
1725
1958
 
1726
1959
  def remove_attribute(name)
1727
1960
  return nil if name.nil?
1728
1961
 
1729
- key = name.to_s.downcase
1962
+ key = normalize_attr_key(name)
1730
1963
  return nil unless @__node__.key?(key)
1731
1964
 
1732
1965
  old = @__node__[key]
@@ -1748,16 +1981,51 @@ module Dommy
1748
1981
  nil
1749
1982
  end
1750
1983
 
1984
+ # Web Animations: start an animation on this element.
1985
+ # Returns the new Animation. Dommy doesn't interpolate; the
1986
+ # animation simply transitions through the `playState` lifecycle,
1987
+ # finishing via `scheduler.advance_time(duration)` or an
1988
+ # explicit `animation.finish`.
1989
+ def animate(keyframes, options = nil)
1990
+ effect = KeyframeEffect.new(self, keyframes, options)
1991
+ animation = Animation.new(effect, nil, window: @document.default_view)
1992
+ @__animations ||= []
1993
+ @__animations << animation
1994
+ animation.play
1995
+ animation
1996
+ end
1997
+
1998
+ def get_animations(_options = nil)
1999
+ (@__animations ||= []).dup
2000
+ end
2001
+
2002
+ alias getAnimations get_animations
2003
+
1751
2004
  def query_selector(selector)
1752
2005
  return nil if selector.nil? || selector.to_s.empty?
1753
2006
 
1754
- @document.wrap_node(@__node__.at_css(selector.to_s))
2007
+ @document.wrap_node(@__node__.at_css(selector.to_s, Internal::CSS_PSEUDO_HANDLERS))
1755
2008
  end
1756
2009
 
1757
2010
  def query_selector_all(selector)
1758
2011
  return NodeList.new if selector.nil? || selector.to_s.empty?
1759
2012
 
1760
- NodeList.new(@__node__.css(selector.to_s).map { |node| @document.wrap_node(node) }.compact)
2013
+ NodeList.new(@__node__.css(selector.to_s, Internal::CSS_PSEUDO_HANDLERS).map { |node| @document.wrap_node(node) }.compact)
2014
+ end
2015
+
2016
+ # XPath queries scoped to this element, returning wrapped nodes.
2017
+ def at_xpath(expression)
2018
+ node = @__node__.at_xpath(expression)
2019
+ node && @document.wrap_node(node)
2020
+ end
2021
+
2022
+ def xpath(expression)
2023
+ @__node__.xpath(expression).map { |node| @document.wrap_node(node) }
2024
+ end
2025
+
2026
+ # The XPath string locating this element in its document.
2027
+ def path
2028
+ @__node__.path
1761
2029
  end
1762
2030
 
1763
2031
  def append_child(child)
@@ -1903,10 +2171,10 @@ module Dommy
1903
2171
  # produce a cycle (inserting an ancestor as a descendant of
1904
2172
  # itself). Strings and Fragments are always safe.
1905
2173
  def check_hierarchy!(child)
1906
- return unless child.respond_to?(:__node__)
2174
+ return unless child.respond_to?(:__dommy_backend_node__)
1907
2175
 
1908
- node = child.__node__
1909
- return unless node.is_a?(Nokogiri::XML::Node)
2176
+ node = child.__dommy_backend_node__
2177
+ return unless node.is_a?(Backend.node_class)
1910
2178
 
1911
2179
  if node == @__node__ || @__node__.ancestors.any? { |a| a == node }
1912
2180
  raise(
@@ -1923,13 +2191,13 @@ module Dommy
1923
2191
  def detach_dom_nodes(value)
1924
2192
  case value
1925
2193
  when Element, TextNode, CommentNode
1926
- node = value.__node__
2194
+ node = value.__dommy_backend_node__
1927
2195
  node.unlink if node.parent
1928
2196
  [node]
1929
2197
  when Fragment
1930
2198
  value.extract_children
1931
2199
  when String
1932
- [@document.create_text_node(value).__node__]
2200
+ [@document.create_text_node(value).__dommy_backend_node__]
1933
2201
  else
1934
2202
  node = unwrap_dom_node(value)
1935
2203
  return [] unless node
@@ -1940,7 +2208,7 @@ module Dommy
1940
2208
  end
1941
2209
 
1942
2210
  def unwrap_dom_node(value)
1943
- return value.__node__ if value.respond_to?(:__node__)
2211
+ return value.__dommy_backend_node__ if value.respond_to?(:__dommy_backend_node__)
1944
2212
 
1945
2213
  nil
1946
2214
  end
@@ -1953,6 +2221,35 @@ module Dommy
1953
2221
  end
1954
2222
  end
1955
2223
 
2224
+ # Popover state — modern HTML pattern. `show`/`hide`/`toggle`
2225
+ # fire `beforetoggle` and `toggle` events (no real visual change).
2226
+ def toggle_popover_state(open)
2227
+ old_state = @__popover_open__ ? "open" : "closed"
2228
+ new_state = open ? "open" : "closed"
2229
+ return if old_state == new_state
2230
+
2231
+ dispatch_event(
2232
+ CustomEvent.new(
2233
+ "beforetoggle",
2234
+ "detail" => {"oldState" => old_state, "newState" => new_state}
2235
+ )
2236
+ )
2237
+ @__popover_open__ = open
2238
+ dispatch_event(
2239
+ CustomEvent.new(
2240
+ "toggle",
2241
+ "detail" => {"oldState" => old_state, "newState" => new_state}
2242
+ )
2243
+ )
2244
+ 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
+
1956
2253
  # Re-expose snake_case methods that the JS bridge dispatch routes
1957
2254
  # to. Defined as private originally so internal helpers (element_children,
1958
2255
  # detach_dom_nodes, etc.) stay encapsulated; CRuby users call these
@@ -1969,7 +2266,13 @@ module Dommy
1969
2266
  :clone_node,
1970
2267
  :query_selector,
1971
2268
  :query_selector_all,
1972
- :closest
2269
+ :at_xpath,
2270
+ :xpath,
2271
+ :path,
2272
+ :closest,
2273
+ :animate,
2274
+ :get_animations,
2275
+ :getAnimations
1973
2276
  )
1974
2277
  end
1975
2278
  end