dommy 0.9.0 → 0.10.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
data/lib/dommy/element.rb CHANGED
@@ -57,6 +57,27 @@ module Dommy
57
57
  @__node__.text
58
58
  end
59
59
 
60
+ def text_content=(value)
61
+ # Replace all children with a single Text node (nullable: null/undefined
62
+ # clear with no replacement). Unlink old children so a removed node keeps
63
+ # its own descendants.
64
+ removed = @__node__.children.to_a
65
+ str = nullable_dom_string(value)
66
+ removed.each(&:unlink)
67
+ @__node__.add_child(@document.create_text_node(str).__dommy_backend_node__) unless str.empty?
68
+ notify_child_list(added: @__node__.children.to_a, removed: removed)
69
+ end
70
+
71
+ def __js_set__(key, value)
72
+ case key
73
+ when "textContent"
74
+ self.text_content = value
75
+ nil
76
+ else
77
+ Bridge::UNHANDLED
78
+ end
79
+ end
80
+
60
81
  def query_selector(selector)
61
82
  return nil if selector.nil?
62
83
  ast = Internal::SelectorParser.parse!(selector)
@@ -84,6 +105,9 @@ module Dommy
84
105
  11
85
106
  when "nodeName"
86
107
  "#document-fragment"
108
+ when "nodeValue"
109
+ # A DocumentFragment's nodeValue is null (not undefined).
110
+ nil
87
111
  when "children"
88
112
  element_children
89
113
  when "childNodes"
@@ -100,6 +124,10 @@ module Dommy
100
124
  last_element_child
101
125
  when "textContent"
102
126
  @__node__.text
127
+ when "parentNode", "parentElement"
128
+ # A DocumentFragment is never inserted, so it has no parent (null, not
129
+ # undefined).
130
+ nil
103
131
  when "ownerDocument"
104
132
  @document
105
133
  else
@@ -146,6 +174,7 @@ module Dommy
146
174
  when "removeChild"
147
175
  remove_child(args[0])
148
176
  when "insertBefore"
177
+ validate_insert_before_ref!(args)
149
178
  insert_before(args[0], args[1])
150
179
  when "replaceChild"
151
180
  replace_child(args[0], args[1])
@@ -192,6 +221,8 @@ module Dommy
192
221
  end
193
222
 
194
223
  def insert_before(node, ref)
224
+ coerce_node_argument!(node)
225
+ ensure_pre_insertion_validity!(node, ref)
195
226
  nodes = detach_dom_nodes(node)
196
227
  ref_bn = ref.respond_to?(:__dommy_backend_node__) ? ref.__dommy_backend_node__ : nil
197
228
  if ref_bn && ref_bn.parent == @__node__
@@ -203,9 +234,11 @@ module Dommy
203
234
  end
204
235
 
205
236
  def replace_child(new_child, old_child)
237
+ coerce_node_argument!(new_child)
206
238
  old_bn = old_child.respond_to?(:__dommy_backend_node__) ? old_child.__dommy_backend_node__ : nil
207
239
  raise DOMException::NotFoundError, "node is not a child of this fragment" unless old_bn && old_bn.parent == @__node__
208
240
 
241
+ ensure_pre_insertion_validity!(new_child, old_child)
209
242
  detach_dom_nodes(new_child).each { |n| old_bn.add_previous_sibling(n) }
210
243
  old_bn.unlink
211
244
  old_child
@@ -221,11 +254,6 @@ module Dommy
221
254
  on == @__node__ || Internal::NodeTraversal.ancestor_of?(@__node__, on)
222
255
  end
223
256
 
224
- def normalize
225
- @__node__.children.each { |n| n.unlink if n.text? && n.content.empty? }
226
- nil
227
- end
228
-
229
257
  private
230
258
 
231
259
  def element_children
@@ -247,6 +275,9 @@ module Dommy
247
275
  class CharacterDataNode
248
276
  include Node
249
277
  include EventTarget
278
+ # `before` / `after` / `replaceWith` (+ their argument coercion) — the same
279
+ # spec-correct implementation Element uses, minus appendChild/insertBefore.
280
+ include Internal::ChildNode
250
281
 
251
282
  # The owning Dommy document (as Element exposes), so cross-document adoption
252
283
  # checks work for text/comment nodes too.
@@ -319,6 +350,12 @@ module Dommy
319
350
  @__node__.parent && @document.wrap_node(@__node__.parent)
320
351
  end
321
352
 
353
+ # parentElement is the parent only when it is an element (a document or
354
+ # fragment parent is a parentNode but not a parentElement).
355
+ def parent_element
356
+ @document.wrap_node(@__node__.parent) if @__node__.parent&.element?
357
+ end
358
+
322
359
  def next_sibling
323
360
  @__node__.next && @document.wrap_node(@__node__.next)
324
361
  end
@@ -351,18 +388,67 @@ module Dommy
351
388
  # MutationObserver record.
352
389
 
353
390
  def length
354
- @__node__.content.length
391
+ utf16_length(@__node__.content)
392
+ end
393
+
394
+ # CharacterData offsets and counts are measured in UTF-16 code units, not
395
+ # Unicode code points, so an astral character (e.g. an emoji) counts as 2.
396
+ def utf16_length(str)
397
+ str.encode(Encoding::UTF_16LE).bytesize / 2
398
+ end
399
+
400
+ # Extract `count` UTF-16 code units from `str` starting at code unit
401
+ # `offset`. Slicing on the UTF-16LE byte buffer keeps astral characters
402
+ # intact for the offsets these APIs actually produce.
403
+ #
404
+ # If the range starts or ends inside a surrogate pair the result would be a
405
+ # lone (unpaired) surrogate. JS strings can hold those; a Ruby UTF-8 String
406
+ # cannot, so re-raise the raw encoding error as a clear, intentional message
407
+ # rather than leaking "\xDF on UTF-16LE" to the caller. This is a Dommy
408
+ # limitation and, since splitting a surrogate pair signals a UTF-16 offset
409
+ # bug in the caller, failing loud is deliberate.
410
+ def utf16_slice(str, offset, count)
411
+ buf = str.encode(Encoding::UTF_16LE)
412
+ buf.byteslice(offset * 2, count * 2).encode(Encoding::UTF_8, Encoding::UTF_16LE)
413
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
414
+ raise "cannot split a UTF-16 surrogate pair: the requested range would " \
415
+ "produce a lone surrogate, which Dommy cannot represent"
355
416
  end
356
417
 
357
418
  def substring_data(offset, count)
358
419
  s = @__node__.content
359
- raise DOMException::IndexSizeError, "offset out of bounds" if offset.to_i.negative? || offset.to_i > s.length
420
+ len = utf16_length(s)
421
+ o = to_uint32(offset)
422
+ raise DOMException::IndexSizeError, "offset out of bounds" if o > len
423
+
424
+ c = [to_uint32(count), len - o].min
425
+ utf16_slice(s, o, c)
426
+ end
427
+
428
+ # ECMAScript ToUint32 — WebIDL `unsigned long` conversion for a data offset
429
+ # or count: ToNumber (a non-numeric string is NaN), truncate toward zero,
430
+ # then take modulo 2**32 (so -1 wraps to 4294967295, 0x100000000+2 to 2).
431
+ def to_uint32(value)
432
+ num =
433
+ case value
434
+ when Integer then value
435
+ when Numeric then value
436
+ when nil then 0 # JS null -> 0
437
+ else Float(value.to_s) rescue Float::NAN
438
+ end
439
+ return 0 unless num.respond_to?(:finite?) ? num.finite? : true
360
440
 
361
- s[offset.to_i, [count.to_i, 0].max].to_s
441
+ num.to_i % (2**32)
362
442
  end
363
443
 
364
444
  def append_data(value)
365
- write_data(@__node__.content + value.to_s)
445
+ write_data(@__node__.content + dom_string(value))
446
+ end
447
+
448
+ # WebIDL DOMString coercion for a CharacterData mutation argument: JS null
449
+ # becomes the string "null" (undefined already stringifies to "undefined").
450
+ def dom_string(value)
451
+ value.nil? ? "null" : value.to_s
366
452
  end
367
453
 
368
454
  def insert_data(offset, value)
@@ -375,11 +461,12 @@ module Dommy
375
461
 
376
462
  def replace_data(offset, count, value)
377
463
  s = @__node__.content
378
- o = offset.to_i
379
- raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > s.length
464
+ len = utf16_length(s)
465
+ o = to_uint32(offset)
466
+ raise DOMException::IndexSizeError, "offset out of bounds" if o > len
380
467
 
381
- c = [[count.to_i, 0].max, s.length - o].min
382
- write_data(s[0, o].to_s + value.to_s + s[(o + c)..].to_s)
468
+ c = [to_uint32(count), len - o].min
469
+ write_data(utf16_slice(s, 0, o) + dom_string(value) + utf16_slice(s, o + c, len - (o + c)))
383
470
  end
384
471
 
385
472
  def __js_get__(key)
@@ -398,6 +485,8 @@ module Dommy
398
485
  length
399
486
  when "parentNode"
400
487
  parent_node
488
+ when "parentElement"
489
+ parent_element
401
490
  when "ownerDocument"
402
491
  @document
403
492
  when "nextSibling"
@@ -412,11 +501,32 @@ module Dommy
412
501
  NodeList.new
413
502
  when "firstChild", "lastChild"
414
503
  nil
504
+ when "assignedSlot"
505
+ assigned_slot
415
506
  else
416
507
  Bridge::ABSENT # unknown property: JS undefined, `in` absent
417
508
  end
418
509
  end
419
510
 
511
+ # Slottable mixin: the <slot> this text node is assigned to. A text node has
512
+ # no `slot` attribute, so it targets the default (unnamed) slot of its parent
513
+ # element's shadow tree; a closed shadow tree hides the assignment (null).
514
+ def assigned_slot
515
+ parent = @__node__.parent
516
+ return nil unless parent.respond_to?(:element?) && parent.element?
517
+
518
+ host = @document.wrap_node(parent)
519
+ return nil unless host.respond_to?(:shadow_root)
520
+
521
+ sr = host.shadow_root
522
+ return nil unless sr
523
+ return nil if sr.__js_get__("mode") == "closed"
524
+
525
+ sr.query_selector_all("slot").find do |slot|
526
+ (slot.respond_to?(:name) ? slot.name.to_s : "") == ""
527
+ end
528
+ end
529
+
420
530
  def __js_set__(key, value)
421
531
  case key
422
532
  when "textContent", "data", "nodeValue"
@@ -441,13 +551,15 @@ module Dommy
441
551
  # A leaf node contains only itself (no descendants).
442
552
  args[0].respond_to?(:__dommy_backend_node__) &&
443
553
  args[0].__dommy_backend_node__ == @__node__
444
- when "appendChild", "insertBefore"
554
+ when "appendChild", "insertBefore", "replaceChild"
445
555
  # WebIDL coerces the Node argument first (null/non-Node → TypeError);
446
- # only then does a leaf node reject any child with HierarchyRequestError.
556
+ # only then does the leaf reject the insertion. WHATWG pre-insert /
557
+ # replace step 1 checks the PARENT type before the reference child, so a
558
+ # leaf parent is a HierarchyRequestError even when `child` isn't a child.
447
559
  raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
448
560
 
449
561
  raise DOMException::HierarchyRequestError, "this node type does not support children"
450
- when "removeChild", "replaceChild"
562
+ when "removeChild"
451
563
  raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
452
564
 
453
565
  raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
@@ -474,6 +586,8 @@ module Dommy
474
586
  when "dispatchEvent"
475
587
  dispatch_event(args[0])
476
588
  when "appendData"
589
+ raise Bridge::TypeError, "appendData requires 1 argument." if args.empty?
590
+
477
591
  append_data(args[0])
478
592
  when "insertData"
479
593
  insert_data(args[0], args[1])
@@ -482,9 +596,12 @@ module Dommy
482
596
  when "replaceData"
483
597
  replace_data(args[0], args[1], args[2])
484
598
  when "substringData"
599
+ raise Bridge::TypeError, "substringData requires 2 arguments." if args.length < 2
600
+
485
601
  substring_data(args[0], args[1])
486
602
  when "remove"
487
603
  remove
604
+ Bridge::UNDEFINED # ChildNode#remove is void -> JS undefined, not null
488
605
  when "before"
489
606
  before(*args)
490
607
  when "after"
@@ -496,86 +613,26 @@ module Dommy
496
613
  end
497
614
  end
498
615
 
499
- # ChildNode mixin: WHATWG DOM defines `before`, `after`,
500
- # `replaceWith` on all child nodes, including Text and Comment.
501
- # Implementations operate on the Nokogiri layer and notify the
502
- # MutationObserver with the underlying nodes (mirroring
503
- # Element#remove_child / replace_child).
616
+ # ChildNode mixin: WHATWG DOM defines `before`, `after`, `replaceWith` on
617
+ # all child nodes, including Text and Comment. The spec-correct algorithm
618
+ # (viable previous/next sibling, forward insertion, string coercion,
619
+ # Fragment/cross-document adoption) lives in Internal::ParentNode and is
620
+ # shared with Element these just forward to it.
504
621
 
505
622
  def before(*args)
506
- parent = @__node__.parent
507
- return nil unless parent
508
-
509
- added = args.map { |arg| coerce_node(arg) }.compact
510
- added.reverse_each { |node| @__node__.add_previous_sibling(node) }
511
- notify_child_list_added(parent, added)
512
- nil
623
+ child_node_before(args)
513
624
  end
514
625
 
515
626
  def after(*args)
516
- parent = @__node__.parent
517
- return nil unless parent
518
-
519
- added = args.map { |arg| coerce_node(arg) }.compact
520
- anchor = @__node__.next_sibling
521
- if anchor
522
- added.reverse_each { |node| anchor.add_previous_sibling(node) }
523
- else
524
- added.each { |node| parent.add_child(node) }
525
- end
526
- notify_child_list_added(parent, added)
527
- nil
627
+ child_node_after(args)
528
628
  end
529
629
 
530
630
  def replace_with(*args)
531
- parent = @__node__.parent
532
- return nil unless parent
533
-
534
- added = args.map { |arg| coerce_node(arg) }.compact
535
- removed = @__node__
536
- anchor = @__node__.next_sibling
537
- @__node__.unlink
538
- if anchor
539
- added.reverse_each { |node| anchor.add_previous_sibling(node) }
540
- else
541
- added.each { |node| parent.add_child(node) }
542
- end
543
- @document.notify_child_list_mutation(
544
- target_node: parent,
545
- added_nodes: added,
546
- removed_nodes: [removed]
547
- )
548
- nil
631
+ child_node_replace_with(args)
549
632
  end
550
633
 
551
634
  private
552
635
 
553
- # Coerce a `before` / `after` / `replaceWith` argument into a raw
554
- # Nokogiri node, ready to be linked into a parent. Strings become
555
- # fresh text nodes; existing nodes are detached from their current
556
- # parent first (matching Element#detach_dom_nodes minus the
557
- # Fragment branch which is rarely needed off a text/comment node).
558
- def coerce_node(arg)
559
- case arg
560
- when String
561
- @document.create_text_node(arg).__dommy_backend_node__
562
- else
563
- node = arg.respond_to?(:__dommy_backend_node__) ? arg.__dommy_backend_node__ : nil
564
- node.unlink if node && node.parent
565
- node
566
- end
567
- end
568
-
569
- def notify_child_list_added(parent, added)
570
- return if added.empty?
571
-
572
- @document.notify_child_list_mutation(
573
- target_node: parent,
574
- added_nodes: added,
575
- removed_nodes: []
576
- )
577
- end
578
-
579
636
  def write_data(value)
580
637
  old = @__node__.content
581
638
  @__node__.content = value.to_s
@@ -588,6 +645,30 @@ module Dommy
588
645
  3
589
646
  end
590
647
 
648
+ # WHATWG Text.wholeText — the concatenated data of this node together with
649
+ # its contiguous, logically-adjacent Text / CDATASection siblings (node
650
+ # types 3 and 4), in document order.
651
+ def whole_text
652
+ run = [@__node__]
653
+ prev = @__node__.previous
654
+ while prev && [3, 4].include?(prev.node_type)
655
+ run.unshift(prev)
656
+ prev = prev.previous
657
+ end
658
+ nxt = @__node__.next
659
+ while nxt && [3, 4].include?(nxt.node_type)
660
+ run.push(nxt)
661
+ nxt = nxt.next
662
+ end
663
+ run.map { |bn| bn.content.to_s }.join
664
+ end
665
+
666
+ def __js_get__(key)
667
+ return whole_text if key == "wholeText"
668
+
669
+ super
670
+ end
671
+
591
672
  # Own __js_call__ methods, on top of CharacterDataNode's.
592
673
  js_methods %w[cloneNode]
593
674
  def __js_call__(method, args)
@@ -1171,11 +1252,12 @@ module Dommy
1171
1252
  end
1172
1253
 
1173
1254
  def write_properties(props)
1174
- if props.empty?
1175
- @element.remove_attribute("style") if @element.__dommy_backend_node__.key?("style")
1176
- else
1177
- @element.set_attribute("style", serialize_properties(props))
1178
- end
1255
+ # Per CSSOM, mutating an inline style declaration serializes it back to the
1256
+ # `style` content attribute. An emptied declaration serializes to "" and
1257
+ # the attribute STAYS present (style="") — it is removed only via an
1258
+ # explicit removeAttribute("style"), never as a side effect of clearing the
1259
+ # last property.
1260
+ @element.set_attribute("style", serialize_properties(props))
1179
1261
  end
1180
1262
  end
1181
1263
 
@@ -1221,17 +1303,16 @@ module Dommy
1221
1303
  end
1222
1304
 
1223
1305
  def text_content=(value)
1224
- # Setting textContent removes all existing children and, only for a
1225
- # non-empty value, appends a single text node. Capture before/after to
1226
- # feed MutationObserver. The empty case clears children directly: the
1227
- # backend's `content=` is the parser's, and Makiri leaves an empty text
1228
- # node behind for "" (Nokogiri and the DOM produce no node at all).
1306
+ # textContent is a nullable DOMString, so null AND undefined both mean "no
1307
+ # value" -> clear the children with no replacement text. Otherwise replace
1308
+ # all children with a single Text node. Unlink the old children (rather
1309
+ # than the backend's `content=`, which frees their whole subtree) so a
1310
+ # reference to a removed node keeps its own descendants intact.
1229
1311
  removed = @__node__.children.to_a
1230
- str = value.to_s
1231
- if str.empty?
1232
- removed.each(&:unlink)
1233
- else
1234
- @__node__.content = str
1312
+ str = nullable_dom_string(value)
1313
+ removed.each(&:unlink)
1314
+ unless str.empty?
1315
+ @__node__.add_child(@document.create_text_node(str).__dommy_backend_node__)
1235
1316
  end
1236
1317
  added = @__node__.children.to_a
1237
1318
  notify_child_list(added: added, removed: removed)
@@ -1533,35 +1614,6 @@ module Dommy
1533
1614
  # WHATWG Node.normalize: drop empty Text nodes and merge each run of
1534
1615
  # contiguous Text nodes into the first, firing the matching mutation records
1535
1616
  # (childList for every removed node, characterData for the merged data).
1536
- def normalize
1537
- text_nodes = []
1538
- @__node__.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
1539
-
1540
- text_nodes.each do |node|
1541
- next unless node.parent # already removed as part of an earlier run
1542
-
1543
- if node.content.to_s.empty?
1544
- @document.remove_node_with_notify(node)
1545
- next
1546
- end
1547
-
1548
- merged = []
1549
- sib = node.next
1550
- while sib.respond_to?(:text?) && sib.text?
1551
- merged << sib
1552
- sib = sib.next
1553
- end
1554
- next if merged.empty?
1555
-
1556
- old = node.content.to_s
1557
- node.content = old + merged.map { |m| m.content.to_s }.join
1558
- @document.notify_character_data_mutation(target_node: node, old_value: old)
1559
- merged.each { |m| @document.remove_node_with_notify(m) }
1560
- end
1561
-
1562
- nil
1563
- end
1564
-
1565
1617
  def toggle_attribute(name, force = nil)
1566
1618
  raise DOMException::InvalidCharacterError, "empty attribute name" if name.to_s.empty?
1567
1619
 
@@ -1596,14 +1648,7 @@ module Dommy
1596
1648
  end
1597
1649
 
1598
1650
  def get_elements_by_tag_name(name)
1599
- n = name.to_s.downcase
1600
- root = @__node__
1601
- doc = @document
1602
- if n == "*"
1603
- HTMLCollection.new { root.css("*").map { |x| doc.wrap_node(x) }.compact }
1604
- else
1605
- HTMLCollection.new { root.css(n).map { |x| doc.wrap_node(x) }.compact }
1606
- end
1651
+ HTMLCollection.elements_by_tag_name(@__node__, @document, name)
1607
1652
  end
1608
1653
 
1609
1654
  def get_elements_by_tag_name_ns(namespace, local_name)
@@ -1616,6 +1661,10 @@ module Dommy
1616
1661
  @attributes ||= NamedNodeMap.new(self)
1617
1662
  end
1618
1663
 
1664
+ # Public bridges to the attribute-name case machinery, for NamedNodeMap.
1665
+ def __internal_normalize_attr_key__(name) = normalize_attr_key(name)
1666
+ def __internal_case_sensitive_attribute_names__? = case_sensitive_attribute_names?
1667
+
1619
1668
  def get_attribute_node(name)
1620
1669
  attributes.get_named_item(name)
1621
1670
  end
@@ -1755,16 +1804,31 @@ module Dommy
1755
1804
 
1756
1805
  alias connected? is_connected?
1757
1806
 
1758
- # `focus()` / `blur()` Dommy has no layout / real focus, but
1759
- # tests rely on `document.activeElement` updating. Track the most
1760
- # recently focused element on the document.
1807
+ # `focus()` the HTML focusing steps, minus layout: Dommy treats any
1808
+ # element as focusable (except a disabled form control), then updates
1809
+ # document.activeElement AND fires the focus-change events a real
1810
+ # browser would — blur/focusout on the previously focused element, then
1811
+ # focus/focusin here, with relatedTarget linking the two. JS calling
1812
+ # `input.focus()` therefore triggers the same focus handlers a user's
1813
+ # click/tab would; already-focused and disabled targets are no-ops.
1761
1814
  def focus
1815
+ return nil if disabled_form_control?
1816
+ return nil if @document.__internal_focused_element__.equal?(self)
1817
+
1818
+ previous = @document.__internal_focused_element__
1819
+ fire_focus_out(previous, self) if previous
1762
1820
  @document.__internal_set_active_element__(self)
1821
+ dispatch_event(Dommy::FocusEvent.new("focus", "composed" => true, "relatedTarget" => previous))
1822
+ dispatch_event(Dommy::FocusEvent.new("focusin",
1823
+ "bubbles" => true, "composed" => true, "relatedTarget" => previous))
1763
1824
  nil
1764
1825
  end
1765
1826
 
1766
1827
  def blur
1828
+ return nil unless @document.__internal_focused_element__.equal?(self)
1829
+
1767
1830
  @document.__internal_set_active_element__(nil)
1831
+ fire_focus_out(self, nil)
1768
1832
  nil
1769
1833
  end
1770
1834
 
@@ -1805,14 +1869,18 @@ module Dommy
1805
1869
  raise DOMException::NotSupportedError, "<#{tag}> cannot host a shadow root"
1806
1870
  end
1807
1871
 
1808
- raise DOMException::InvalidStateError, "Shadow root already attached" if @__shadow_root
1872
+ raise DOMException::NotSupportedError, "Shadow root already attached" if @__shadow_root
1809
1873
 
1810
1874
  opts = options.is_a?(Hash) ? options : {}
1811
1875
  mode_raw = opts.key?("mode") ? opts["mode"] : opts[:mode]
1812
- raise TypeError, "attachShadow init dictionary requires 'mode'" if mode_raw.nil?
1876
+ # `mode` is a required WebIDL dictionary member omitting it, like an
1877
+ # invalid enum value below, is a (JS) TypeError, not a DOMException.
1878
+ raise Bridge::TypeError, "attachShadow init dictionary requires 'mode'" if mode_raw.nil?
1813
1879
 
1880
+ # `mode` is a WebIDL enum (ShadowRootMode); a value that isn't "open"/
1881
+ # "closed" fails enum conversion → TypeError, not a DOMException.
1814
1882
  mode = mode_raw.to_s
1815
- raise DOMException::SyntaxError, "mode must be 'open' or 'closed'" unless %w[open closed].include?(mode)
1883
+ raise Bridge::TypeError, "mode must be 'open' or 'closed'" unless %w[open closed].include?(mode)
1816
1884
 
1817
1885
  @__shadow_root = ShadowRoot.new(
1818
1886
  self,
@@ -1841,16 +1909,26 @@ module Dommy
1841
1909
  # `el.insertAdjacentElement(position, element)` — DOM spec positions:
1842
1910
  # "beforebegin", "afterbegin", "beforeend", "afterend". Returns the
1843
1911
  # inserted element or nil if position has no anchor (root cases).
1912
+ ADJACENT_POSITIONS = %w[beforebegin afterbegin beforeend afterend].freeze
1913
+
1844
1914
  def insert_adjacent_element(position, element)
1915
+ # Position is an ASCII case-insensitive enum; anything else is a SyntaxError
1916
+ # (checked before the node coercion / anchor lookup, per spec).
1917
+ pos = position.to_s.downcase
1918
+ unless ADJACENT_POSITIONS.include?(pos)
1919
+ raise DOMException::SyntaxError, "'#{position}' is not a valid insertAdjacent position."
1920
+ end
1845
1921
  return nil unless element.respond_to?(:__dommy_backend_node__)
1846
1922
 
1847
- case position.to_s
1923
+ case pos
1848
1924
  when "beforebegin"
1849
- return nil unless @__node__.parent
1925
+ parent = @__node__.parent
1926
+ return nil unless parent
1850
1927
 
1928
+ validate_adjacent_document_insert!(parent, element)
1851
1929
  node = detach_for_insert(element)
1852
1930
  @__node__.add_previous_sibling(node)
1853
- notify_child_list(added: [node], target: @__node__.parent)
1931
+ notify_child_list(added: [node], target: parent)
1854
1932
  when "afterbegin"
1855
1933
  node = detach_for_insert(element)
1856
1934
  first = @__node__.children.first
@@ -1861,18 +1939,27 @@ module Dommy
1861
1939
  @__node__.add_child(node)
1862
1940
  notify_child_list(added: [node])
1863
1941
  when "afterend"
1864
- return nil unless @__node__.parent
1942
+ parent = @__node__.parent
1943
+ return nil unless parent
1865
1944
 
1945
+ validate_adjacent_document_insert!(parent, element)
1866
1946
  node = detach_for_insert(element)
1867
1947
  @__node__.add_next_sibling(node)
1868
- notify_child_list(added: [node], target: @__node__.parent)
1869
- else
1870
- return nil
1948
+ notify_child_list(added: [node], target: parent)
1871
1949
  end
1872
1950
 
1873
1951
  element
1874
1952
  end
1875
1953
 
1954
+ # beforebegin / afterend insert a sibling — when this element's parent is the
1955
+ # document, that would add a second document child, so run the document's
1956
+ # WHATWG pre-insertion hierarchy check (a second root element is rejected).
1957
+ def validate_adjacent_document_insert!(parent, element)
1958
+ return unless parent == @document.backend_doc
1959
+
1960
+ @document.ensure_document_insertion_validity!([element], @__node__)
1961
+ end
1962
+
1876
1963
  def insert_adjacent_html(position, html)
1877
1964
  # Position is ASCII case-insensitive ("beforeBegin" == "beforebegin").
1878
1965
  pos = position.to_s.downcase
@@ -1993,15 +2080,15 @@ module Dommy
1993
2080
  # ChildNode mixin — before / after / replaceWith with mixed args.
1994
2081
 
1995
2082
  def before(*args)
1996
- insert_adjacent(:before, args)
2083
+ child_node_before(args)
1997
2084
  end
1998
2085
 
1999
2086
  def after(*args)
2000
- insert_adjacent(:after, args)
2087
+ child_node_after(args)
2001
2088
  end
2002
2089
 
2003
2090
  def replace_with_nodes(*args)
2004
- replace_with(args)
2091
+ child_node_replace_with(args)
2005
2092
  end
2006
2093
 
2007
2094
  # `getInnerHTML()` — happy-dom alias for the `innerHTML` getter.
@@ -2022,19 +2109,19 @@ module Dommy
2022
2109
  # activation behavior (the default) just dispatch the event.
2023
2110
  def click
2024
2111
  pre = pre_click_activation_state
2025
- not_canceled = dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
2026
- return not_canceled if pre.nil?
2027
-
2112
+ event = MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0)
2113
+ not_canceled = dispatch_event(event)
2028
2114
  if not_canceled
2029
- run_post_click_activation(pre)
2030
- else
2115
+ run_post_click_activation(pre) unless pre.nil?
2116
+ __run_click_activation_behavior__(event)
2117
+ elsif pre
2031
2118
  restore_pre_click_activation(pre)
2032
2119
  end
2033
2120
  not_canceled
2034
2121
  end
2035
2122
 
2036
- # Activation-behavior hooks. The default element has none; HTMLInputElement
2037
- # overrides these for checkbox/radio.
2123
+ # Pre-click activation hooks (checkbox/radio toggle-then-maybe-revert). The
2124
+ # default element has none; HTMLInputElement overrides these.
2038
2125
  def pre_click_activation_state
2039
2126
  nil
2040
2127
  end
@@ -2043,10 +2130,59 @@ module Dommy
2043
2130
 
2044
2131
  def restore_pre_click_activation(_state); end
2045
2132
 
2133
+ # Activation behavior: the default action of a non-canceled click (a
2134
+ # hyperlink navigates; a submit button submits its form — added later). The
2135
+ # default element has none. Called on the *activation target*.
2136
+ def activation_behavior(_event); end
2137
+
2138
+ # An element is an "activation target" when it carries its own activation
2139
+ # behavior (a hyperlink). Default: no.
2140
+ def activation_target?
2141
+ false
2142
+ end
2143
+
2144
+ # The activation target for a click on this element: the nearest inclusive
2145
+ # ancestor that is an activation target, or nil — so clicking a <span> inside
2146
+ # an <a href> activates the anchor.
2147
+ def activation_target
2148
+ node = self
2149
+ while node
2150
+ return node if node.respond_to?(:activation_target?) && node.activation_target?
2151
+
2152
+ node = node.respond_to?(:parent_element) ? node.parent_element : nil
2153
+ end
2154
+ nil
2155
+ end
2156
+
2157
+ # Run the activation target's activation behavior after a non-canceled click.
2158
+ # Shared by Element#click (JS `.click()`) and synthetic clicks
2159
+ # (EventSynthesis) so a real default action fires from both paths.
2160
+ def __run_click_activation_behavior__(event)
2161
+ activation_target&.activation_behavior(event)
2162
+ end
2163
+
2046
2164
  def get_attribute_names
2047
2165
  Backend.attribute_nodes(@__node__).map(&:name)
2048
2166
  end
2049
2167
 
2168
+ # A plain {name => value} snapshot of ALL attributes, for the JS bridge's
2169
+ # per-proxy attribute cache (see host_runtime.js): one crossing answers
2170
+ # every subsequent getAttribute/hasAttribute until the DOM epoch moves.
2171
+ # nil for an element whose attribute lookups are case-SENSITIVE (foreign
2172
+ # namespace) — the JS side then keeps the per-call bridge path. Keys are
2173
+ # as stored (already lowercased for HTML elements), so a JS-side
2174
+ # `name.toLowerCase()` lookup matches get_attribute's normalize_attr_key.
2175
+ def __js_attribute_snapshot__
2176
+ return nil if case_sensitive_attribute_names?
2177
+
2178
+ # Two attributes can share a qualified name (differing only by namespace);
2179
+ # get-an-attribute-by-name returns the FIRST in list order, so keep the
2180
+ # first occurrence (Ruby's Array#to_h would keep the last).
2181
+ Backend.attribute_nodes(@__node__).each_with_object({}) do |a, snapshot|
2182
+ snapshot[a.name] = a.value.to_s unless snapshot.key?(a.name)
2183
+ end
2184
+ end
2185
+
2050
2186
  # No real layout engine. By default geometry getters return zeroed rects;
2051
2187
  # when the window opts into approximate geometry (window.approximate_layout)
2052
2188
  # they return non-zero estimates from a cheap pseudo-layout so a site that
@@ -2174,7 +2310,9 @@ module Dommy
2174
2310
  when "firstElementChild"
2175
2311
  first_element_child
2176
2312
  when "parentElement", "parent"
2177
- wrap_parent(@__node__.parent)
2313
+ # parentElement is null unless the parent is an element (the document /
2314
+ # a fragment parent is a parentNode but not a parentElement).
2315
+ @__node__.parent&.element? ? wrap_parent(@__node__.parent) : nil
2178
2316
  when "parentNode"
2179
2317
  # `parentNode` is broader than `parentElement` — includes
2180
2318
  # DocumentFragment / Document parents too. Reconcilers use
@@ -2182,6 +2320,11 @@ module Dommy
2182
2320
  @__node__.parent && @document.wrap_node(@__node__.parent)
2183
2321
  when "textContent"
2184
2322
  @__node__.text
2323
+ when "nodeValue"
2324
+ # Per DOM, an Element's nodeValue is always null (only CharacterData /
2325
+ # Attr carry a value). Without this it fell through to ABSENT → JS
2326
+ # `undefined`, which fails `assert_equals(el.nodeValue, null)`.
2327
+ nil
2185
2328
  when "innerHTML"
2186
2329
  inner_html
2187
2330
  when "outerHTML"
@@ -2212,6 +2355,15 @@ module Dommy
2212
2355
  @__node__["class"].to_s
2213
2356
  when "id"
2214
2357
  @__node__["id"].to_s
2358
+ when "lang"
2359
+ # The `lang` IDL attribute reflects the `lang` content attribute (own
2360
+ # value, "" when absent) — not the inherited/computed language.
2361
+ @__node__["lang"].to_s
2362
+ when "translate"
2363
+ # `translate` is a boolean reflecting the element's translation mode,
2364
+ # which inherits: translate="yes"/"" → true, "no" → false, else the
2365
+ # nearest ancestor's mode; the root defaults to translate (true).
2366
+ translate_mode?
2215
2367
  when "hidden", "disabled", "checked", "readOnly", "multiple", "required"
2216
2368
  # Boolean reflected properties — true iff the matching HTML
2217
2369
  # attribute is present. Real DOM normalizes attribute names to
@@ -2309,10 +2461,11 @@ module Dommy
2309
2461
  # `ariaErrorMessageElement` → "aria-errormessage"), or nil. The IDL name is
2310
2462
  # `aria<Xxx>Element`; the content attribute is "aria-" + <Xxx> lowercased.
2311
2463
  def aria_element_attr(key)
2312
- return nil unless key.is_a?(String) && key.start_with?("aria") && key.end_with?("Element")
2313
- return nil unless key.length > 11 && key[4] =~ /[A-Z]/
2314
-
2315
- "aria-#{key[4...-7].downcase}"
2464
+ # Only aria-activedescendant reflects as a SINGULAR element reference; every
2465
+ # other ARIA element reference (controls / describedby / details /
2466
+ # errormessage / flowto / labelledby / owns) is plural (aria*Elements), so
2467
+ # e.g. `ariaErrorMessageElement` must not exist.
2468
+ key == "ariaActiveDescendantElement" ? "aria-activedescendant" : nil
2316
2469
  end
2317
2470
 
2318
2471
  # Read an ARIA element reference: an explicitly-set Element wins; otherwise
@@ -2320,7 +2473,13 @@ module Dommy
2320
2473
  # this element's tree), or null.
2321
2474
  def aria_element_get(content_attr, key)
2322
2475
  explicit = (@aria_element_refs ||= {})[key]
2323
- return explicit if explicit
2476
+ if explicit
2477
+ # An explicitly-set attr-element is only observable while it stays in a
2478
+ # valid scope: a shadow-including descendant of one of this element's
2479
+ # shadow-including ancestors. A reference that crosses into a shadow tree
2480
+ # (or whose target is reparented out of scope) reads as null.
2481
+ return aria_ref_in_valid_scope?(explicit) ? explicit : nil
2482
+ end
2324
2483
 
2325
2484
  idref = @__node__[content_attr].to_s
2326
2485
  return nil if idref.empty?
@@ -2328,6 +2487,27 @@ module Dommy
2328
2487
  aria_find_in_root(idref)
2329
2488
  end
2330
2489
 
2490
+ # WHATWG "reflecting element references" scope check: `attr_element` is valid
2491
+ # iff its root is this element's root or a shadow-including-ancestor root
2492
+ # (reached by hopping each shadow root to its host). So a same-tree reference
2493
+ # and a reference to a shadow-inclusive ancestor are valid, but crossing into
2494
+ # a shadow tree (or a sibling/detached scope) is not.
2495
+ def aria_ref_in_valid_scope?(attr_element)
2496
+ return false unless attr_element.respond_to?(:root_node)
2497
+
2498
+ target_root = attr_element.root_node
2499
+ scope = self
2500
+ loop do
2501
+ root = scope.root_node
2502
+ return true if root.equal?(target_root)
2503
+
2504
+ host = root.respond_to?(:host) ? root.host : nil
2505
+ return false unless host
2506
+
2507
+ scope = host
2508
+ end
2509
+ end
2510
+
2331
2511
  # Set an ARIA element reference: null/undefined clears it and removes the
2332
2512
  # content attribute; an Element stores the explicit reference and sets the
2333
2513
  # content attribute to the empty string (per the reflection spec).
@@ -2337,6 +2517,9 @@ module Dommy
2337
2517
  refs.delete(key)
2338
2518
  remove_attribute(content_attr) if @__node__.key?(content_attr)
2339
2519
  else
2520
+ # WebIDL: the value is an `Element?` — a non-Element throws a TypeError.
2521
+ raise Bridge::TypeError, "value is not an Element or null" unless value.is_a?(Dommy::Element)
2522
+
2340
2523
  # set_attribute clears explicit refs via its aria-* hook, so store the
2341
2524
  # new reference afterward.
2342
2525
  set_attribute(content_attr, "")
@@ -2360,8 +2543,24 @@ module Dommy
2360
2543
  # explicitly-set array wins; otherwise the content attribute is split as a
2361
2544
  # space-separated IDREF list and each resolved (missing ids dropped).
2362
2545
  def aria_elements_get(content_attr, key)
2546
+ # null when there are neither explicit elements nor a content attribute.
2547
+ return nil if aria_elements_current(content_attr, key).nil?
2548
+
2549
+ # Otherwise a per-property memoized live list, so repeated reads return the
2550
+ # [SameObject] (WebIDL requires a stable FrozenArray) while its contents track
2551
+ # the current references/IDREFs.
2552
+ lists = (@aria_elements_lists ||= {})
2553
+ lists[key] ||= LiveNodeList.new { aria_elements_current(content_attr, key) || [] }
2554
+ end
2555
+
2556
+ # The current resolved element list for a plural ARIA element reference, or
2557
+ # nil when neither explicit elements nor the content attribute are present. An
2558
+ # explicitly-set list wins (out-of-scope entries dropped); otherwise the
2559
+ # content attribute is split as space-separated IDREFs and each resolved.
2560
+ def aria_elements_current(content_attr, key)
2363
2561
  explicit = (@aria_elements_refs ||= {})[key]
2364
- return explicit.dup if explicit
2562
+ return explicit.select { |el| aria_ref_in_valid_scope?(el) } if explicit
2563
+ return nil unless @__node__.key?(content_attr)
2365
2564
 
2366
2565
  @__node__[content_attr].to_s.split(/[ \t\n\f\r]+/).reject(&:empty?).filter_map do |id|
2367
2566
  aria_find_in_root(id)
@@ -2387,8 +2586,14 @@ module Dommy
2387
2586
  refs.delete(key)
2388
2587
  remove_attribute(content_attr) if @__node__.key?(content_attr)
2389
2588
  else
2589
+ # WebIDL: the value is a `sequence<Element>?` — a non-array, or an array
2590
+ # containing a non-Element, throws a TypeError.
2591
+ unless value.is_a?(Array) && value.all? { |el| el.is_a?(Dommy::Element) }
2592
+ raise Bridge::TypeError, "value is not a sequence of Elements"
2593
+ end
2594
+
2390
2595
  set_attribute(content_attr, "")
2391
- refs[key] = Array(value)
2596
+ refs[key] = value.dup
2392
2597
  end
2393
2598
  nil
2394
2599
  end
@@ -2436,6 +2641,24 @@ module Dommy
2436
2641
  {"readOnly" => "readonly"}.fetch(key, key)
2437
2642
  end
2438
2643
 
2644
+ # The element's translation mode (HTML `translate`): the nearest ancestor-or-
2645
+ # self with a valid translate attribute decides ("yes"/"" → true, "no" →
2646
+ # false); with none, the root default is translate (true).
2647
+ def translate_mode?
2648
+ node = self
2649
+ while node
2650
+ attr = node.respond_to?(:get_attribute) ? node.get_attribute("translate") : nil
2651
+ unless attr.nil?
2652
+ value = attr.to_s.downcase
2653
+ return true if value == "yes" || value.empty?
2654
+ return false if value == "no"
2655
+ # An invalid value inherits — keep walking up.
2656
+ end
2657
+ node = node.respond_to?(:parent_element) ? node.parent_element : nil
2658
+ end
2659
+ true
2660
+ end
2661
+
2439
2662
  def __js_set__(key, value)
2440
2663
  case key
2441
2664
  when "textContent"
@@ -2461,6 +2684,11 @@ module Dommy
2461
2684
  # Handling it here stops the bridge from stashing a string expando that
2462
2685
  # would shadow the CSSStyleDeclaration getter.
2463
2686
  @style.css_text = value.nil? ? "" : value.to_s
2687
+ when "lang"
2688
+ set_attribute("lang", value.to_s)
2689
+ when "translate"
2690
+ # The setter is a plain boolean → "yes" / "no".
2691
+ set_attribute("translate", value ? "yes" : "no")
2464
2692
  when "className"
2465
2693
  set_attribute("class", value.to_s)
2466
2694
  when "classList"
@@ -2504,7 +2732,7 @@ module Dommy
2504
2732
  getAttribute setAttribute hasAttribute removeAttribute getAttributeNames closest
2505
2733
  getAttributeNS setAttributeNS hasAttributeNS removeAttributeNS getAttributeNodeNS setAttributeNodeNS
2506
2734
  querySelector querySelectorAll getElementsByClassName getElementsByTagName getElementsByTagNameNS
2507
- insertAdjacentElement insertAdjacentHTML insertAdjacentText toggleAttribute matches
2735
+ insertAdjacentElement insertAdjacentHTML insertAdjacentText toggleAttribute matches webkitMatchesSelector
2508
2736
  toString getAttributeNode setAttributeNode removeAttributeNode focus blur attachShadow
2509
2737
  addEventListener removeEventListener dispatchEvent appendChild insertBefore removeChild
2510
2738
  replaceChild cloneNode append prepend replaceChildren before after getInnerHTML getHTML
@@ -2574,10 +2802,12 @@ module Dommy
2574
2802
  insert_adjacent_text(args[0], args[1])
2575
2803
  when "toggleAttribute"
2576
2804
  toggle_attribute(args[0], args[1])
2577
- when "matches"
2805
+ when "matches", "webkitMatchesSelector"
2578
2806
  raise Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
2579
2807
 
2580
- matches?(args[0])
2808
+ # WebIDL DOMString: a null selector coerces to "null" (so `<null>` matches),
2809
+ # undefined to "undefined". webkitMatchesSelector is a legacy alias.
2810
+ matches?(args[0].nil? ? "null" : args[0])
2581
2811
  when "isEqualNode"
2582
2812
  is_equal_node(args[0])
2583
2813
  when "isSameNode"
@@ -2615,6 +2845,7 @@ module Dommy
2615
2845
  when "appendChild"
2616
2846
  append_child(args[0])
2617
2847
  when "insertBefore"
2848
+ validate_insert_before_ref!(args)
2618
2849
  insert_before(args[0], args[1])
2619
2850
  when "removeChild"
2620
2851
  remove_child(args[0])
@@ -2629,15 +2860,16 @@ module Dommy
2629
2860
  when "replaceChildren"
2630
2861
  replace_children(*args)
2631
2862
  when "before"
2632
- insert_adjacent(:before, args)
2863
+ child_node_before(args)
2633
2864
  when "after"
2634
- insert_adjacent(:after, args)
2865
+ child_node_after(args)
2635
2866
  when "getInnerHTML", "getHTML"
2636
2867
  inner_html
2637
2868
  when "remove"
2638
2869
  remove
2870
+ Bridge::UNDEFINED # ChildNode#remove is void -> JS undefined, not null
2639
2871
  when "replaceWith"
2640
- replace_with(args)
2872
+ child_node_replace_with(args)
2641
2873
  when "click"
2642
2874
  click
2643
2875
  when "getBoundingClientRect"
@@ -2673,6 +2905,19 @@ module Dommy
2673
2905
  # like "0"/":"/"invalid^Name" are deliberately treated as valid).
2674
2906
  raise DOMException::InvalidCharacterError, "empty attribute name" if name.to_s.empty?
2675
2907
 
2908
+ # A case-sensitive element (non-HTML namespace, or any element in a non-HTML
2909
+ # document) must preserve the attribute name's case, but a plain `node[name]=`
2910
+ # write goes through the HTML backend which ASCII-lowercases it. Route an
2911
+ # upper-cased name through the case-preserving namespace setter (null
2912
+ # namespace) to keep the case; lower-case names take the fast path unchanged.
2913
+ qn = name.to_s
2914
+ if case_sensitive_attribute_names? && qn.match?(/[A-Z]/)
2915
+ old = Backend.get_attribute_ns(@__node__, nil, qn)
2916
+ Backend.set_attribute_ns(@__node__, nil, nil, qn, qn, value.to_s)
2917
+ @document.notify_attribute_mutation(target_node: @__node__, attribute_name: qn, old_value: old)
2918
+ return nil
2919
+ end
2920
+
2676
2921
  key = normalize_attr_key(name)
2677
2922
  old = @__node__[key]
2678
2923
  @__node__[key] = value.to_s
@@ -2696,9 +2941,16 @@ module Dommy
2696
2941
  return nil unless @__node__.key?(key)
2697
2942
 
2698
2943
  old = @__node__[key]
2699
- # Detach the cached Attr (caching its value) *before* the backend drop,
2700
- # so a held reference keeps the value it had when removed.
2701
- @attributes&.__internal_evict__(nil, key)
2944
+ # Detach the cached Attr (caching its value) *before* the backend drop, so a
2945
+ # held reference keeps the value it had when removed and reports
2946
+ # `ownerElement === null` (so it's no longer "in use"). An attribute set via
2947
+ # setAttributeNS may carry a namespace, so evict by the removed node's real
2948
+ # (namespace, localName) rather than assuming the null namespace.
2949
+ if @attributes
2950
+ removed = Backend.attribute_nodes(@__node__).find { |a| Backend.attribute_ns_info(a)[:qualified_name] == key }
2951
+ info = removed && Backend.attribute_ns_info(removed)
2952
+ @attributes.__internal_evict__(info ? info[:namespace_uri] : nil, info ? info[:local_name] : key)
2953
+ end
2702
2954
  @__node__.remove_attribute(key)
2703
2955
  # Removing an `aria-*` IDREF attribute also clears any explicitly-set
2704
2956
  # element reference (the IDL getter then returns null).
@@ -2712,15 +2964,13 @@ module Dommy
2712
2964
  def get_attribute_ns(namespace, local_name)
2713
2965
  return nil if local_name.nil?
2714
2966
 
2715
- ns = namespace.to_s
2716
- Backend.get_attribute_ns(@__node__, ns.empty? ? nil : ns, local_name.to_s)
2967
+ Backend.get_attribute_ns(@__node__, namespace_arg(namespace), local_name.to_s)
2717
2968
  end
2718
2969
 
2719
2970
  def has_attribute_ns?(namespace, local_name)
2720
2971
  return false if local_name.nil?
2721
2972
 
2722
- ns = namespace.to_s
2723
- Backend.has_attribute_ns?(@__node__, ns.empty? ? nil : ns, local_name.to_s)
2973
+ Backend.has_attribute_ns?(@__node__, namespace_arg(namespace), local_name.to_s)
2724
2974
  end
2725
2975
 
2726
2976
  def set_attribute_ns(namespace, qualified_name, value)
@@ -2734,8 +2984,7 @@ module Dommy
2734
2984
  def remove_attribute_ns(namespace, local_name)
2735
2985
  return nil if local_name.nil?
2736
2986
 
2737
- ns = namespace.to_s
2738
- ns = nil if ns.empty?
2987
+ ns = namespace_arg(namespace)
2739
2988
  local = local_name.to_s
2740
2989
  old = Backend.get_attribute_ns(@__node__, ns, local)
2741
2990
  @attributes&.__internal_evict__(ns, local)
@@ -2881,7 +3130,12 @@ module Dommy
2881
3130
 
2882
3131
  # Capture the insertion point (old's next sibling) before detaching the new
2883
3132
  # child, which may itself be old (replaceChild(x, x)) or old's sibling.
3133
+ # WHATWG: if that reference child IS the node being inserted (new_child is
3134
+ # old's next sibling), advance it to new_child's next sibling so the node
3135
+ # lands in old's slot rather than being appended.
2884
3136
  anchor = old_node.next_sibling
3137
+ new_bn = unwrap_dom_node(new_child)
3138
+ anchor = anchor.next_sibling if anchor && new_bn && anchor == new_bn
2885
3139
  new_nodes = detach_dom_nodes(new_child)
2886
3140
  anchor = nil if anchor && anchor.parent != @__node__
2887
3141
 
@@ -2911,7 +3165,21 @@ module Dommy
2911
3165
  # preserves the element's namespace and attributes (createElement would
2912
3166
  # lose the namespace).
2913
3167
  copy = Backend.clone_node(@__node__, deep: deep_arg)
2914
- @document.wrap_node(copy)
3168
+ # The backend (lexbor, HTML-only) doesn't retain the createElementNS
3169
+ # prefix/local/qualified-name/namespace, so rebuild the clone's wrapper from
3170
+ # that metadata — routing the interface class by the local name and
3171
+ # reapplying tagName/localName/prefix/namespaceURI. Otherwise the clone loses
3172
+ # its prefix/case and resolves to HTMLUnknownElement.
3173
+ clone =
3174
+ if @__ns_qname
3175
+ @document.wrap_cloned_element_ns(copy, @__ns_uri, @__ns_prefix, @__ns_local, @__ns_qname)
3176
+ else
3177
+ @document.wrap_node(copy)
3178
+ end
3179
+ # HTML cloning steps: propagate form-control dirty state (an input's value /
3180
+ # checkedness, …) that lives on the wrapper, not the backend node.
3181
+ @document.__internal_apply_cloning_steps__(@__node__, copy, deep_arg)
3182
+ clone
2915
3183
  end
2916
3184
 
2917
3185
  # Test inspector for scroll calls (no real layout to scroll).
@@ -2922,33 +3190,41 @@ module Dommy
2922
3190
  # ---- Internal helpers (single private section) ----
2923
3191
  private
2924
3192
 
2925
- def attribute_signature
2926
- Backend.attribute_nodes(@__node__).map { |a| [a.name, a.value] }.sort
3193
+ # blur (at the element) then focusout (bubbling), per UI Events order.
3194
+ def fire_focus_out(element, new_target)
3195
+ element.dispatch_event(Dommy::FocusEvent.new("blur", "composed" => true, "relatedTarget" => new_target))
3196
+ element.dispatch_event(Dommy::FocusEvent.new("focusout",
3197
+ "bubbles" => true, "composed" => true, "relatedTarget" => new_target))
3198
+ nil
2927
3199
  end
2928
3200
 
2929
- # on* event-handler property helpers.
2930
- def event_name_from_on(key)
2931
- key.to_s.sub(/\Aon/, "").downcase
3201
+ # A disabled form control cannot be focused (HTML focusability). Other
3202
+ # elements are all treated as focusable — no layout means no visibility /
3203
+ # tabindex modelling.
3204
+ def disabled_form_control?
3205
+ %w[input button select textarea].include?(local_name) && has_attribute?("disabled")
2932
3206
  end
2933
3207
 
2934
- def set_on_handler(event_name, value)
2935
- @on_handlers ||= {}
2936
- previous = @on_handlers[event_name]
2937
- remove_event_listener(event_name, previous) if previous
2938
- if value
2939
- add_event_listener(event_name, value)
2940
- @on_handlers[event_name] = value
2941
- else
2942
- @on_handlers.delete(event_name)
2943
- end
3208
+ def attribute_signature
3209
+ Backend.attribute_nodes(@__node__).map { |a| [a.name, a.value] }.sort
2944
3210
  end
2945
3211
 
3212
+ # on* event-handler property helpers.
2946
3213
  # Attribute-key / child-wrapping / event-parent helpers.
2947
3214
  def normalize_attr_key(name)
2948
3215
  s = name.to_s
2949
3216
  case_sensitive_attribute_names? ? s : s.downcase
2950
3217
  end
2951
3218
 
3219
+ # WebIDL nullable-DOMString namespace argument (*AttributeNS): JS null and
3220
+ # undefined, and the empty string, all denote the null namespace.
3221
+ def namespace_arg(namespace)
3222
+ return nil if namespace.nil? || namespace.equal?(Bridge::UNDEFINED)
3223
+
3224
+ s = namespace.to_s
3225
+ s.empty? ? nil : s
3226
+ end
3227
+
2952
3228
  def element_children
2953
3229
  @__node__.element_children.each_with_object([]) do |node, out|
2954
3230
  wrapped = @document.wrap_node(node)
@@ -2987,51 +3263,15 @@ module Dommy
2987
3263
  # Subclasses with a known namespace override `case_sensitive_attribute_names?`
2988
3264
  # to flip the behavior. Generic Element nodes inspect the namespace
2989
3265
  # URI directly.
3266
+ # Attribute qualified names are ASCII-lowercased (case-insensitive) only for an
3267
+ # element in the HTML namespace within an HTML document; every other case — a
3268
+ # non-HTML (or null) namespace, or any element in a non-HTML document —
3269
+ # preserves case (WHATWG "set/get/has attribute" lowercasing condition).
2990
3270
  def case_sensitive_attribute_names?
2991
- ns = namespace_uri
2992
- !ns.nil? && ns != "http://www.w3.org/1999/xhtml"
3271
+ !(namespace_uri == "http://www.w3.org/1999/xhtml" && @document.html_document?)
2993
3272
  end
2994
3273
 
2995
3274
  # Insertion / scroll / popover helpers.
2996
- def insert_adjacent(side, args)
2997
- parent = @__node__.parent
2998
- return nil unless parent
2999
-
3000
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
3001
- case side
3002
- when :before
3003
- nodes.reverse_each { |node| @__node__.add_previous_sibling(node) }
3004
- when :after
3005
- anchor = @__node__.next_sibling
3006
- if anchor
3007
- nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
3008
- else
3009
- nodes.each { |node| parent.add_child(node) }
3010
- end
3011
- end
3012
-
3013
- notify_child_list(added: nodes, target: parent)
3014
- nil
3015
- end
3016
-
3017
- def replace_with(args)
3018
- parent = @__node__.parent
3019
- return nil unless parent
3020
-
3021
- nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
3022
- removed = @__node__
3023
- anchor = @__node__.next_sibling
3024
- @__node__.unlink
3025
- if anchor
3026
- nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
3027
- else
3028
- nodes.each { |node| parent.add_child(node) }
3029
- end
3030
-
3031
- notify_child_list(added: nodes, removed: [removed], target: parent)
3032
- nil
3033
- end
3034
-
3035
3275
  def append_dom_nodes(nodes)
3036
3276
  nodes.each { |node| @__node__.add_child(node) }
3037
3277
  end