dommy 0.5.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 +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,1975 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "parser"
6
+
7
+ module Dommy
8
+ class Fragment
9
+ include EventTarget
10
+ include Node
11
+
12
+ attr_reader :__node__, :document
13
+
14
+ def initialize(document, nokogiri_node)
15
+ @document = document
16
+ @__node__ = nokogiri_node
17
+ end
18
+
19
+ # Public Ruby API (DocumentFragment surface)
20
+
21
+ def children
22
+ element_children
23
+ end
24
+
25
+ def child_element_count
26
+ @__node__.element_children.size
27
+ end
28
+
29
+ def child_nodes
30
+ NodeList.new(@__node__.children.map { |n| @document.wrap_node(n) }.compact)
31
+ end
32
+
33
+ def first_child
34
+ @document.wrap_node(@__node__.children.first)
35
+ end
36
+
37
+ def last_child
38
+ @document.wrap_node(@__node__.children.last)
39
+ end
40
+
41
+ def first_element_child
42
+ @document.wrap_node(@__node__.children.find(&:element?))
43
+ end
44
+
45
+ def last_element_child
46
+ @document.wrap_node(@__node__.element_children.last)
47
+ end
48
+
49
+ def text_content
50
+ @__node__.text
51
+ end
52
+
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
+ def query_selector(selector)
61
+ return nil if selector.nil? || selector.to_s.empty?
62
+
63
+ @document.wrap_node(@__node__.at_css(selector.to_s))
64
+ end
65
+
66
+ def query_selector_all(selector)
67
+ return NodeList.new if selector.nil? || selector.to_s.empty?
68
+
69
+ NodeList.new(@__node__.css(selector.to_s).map { |n| @document.wrap_node(n) }.compact)
70
+ end
71
+
72
+ def get_element_by_id(id)
73
+ return nil if id.nil?
74
+
75
+ @document.wrap_node(@__node__.at_css("##{id}"))
76
+ end
77
+
78
+ def __js_get__(key)
79
+ case key
80
+ when "nodeType"
81
+ 11
82
+ when "children"
83
+ element_children
84
+ when "childNodes"
85
+ child_nodes
86
+ when "childElementCount"
87
+ child_element_count
88
+ when "firstChild"
89
+ first_child
90
+ when "lastChild"
91
+ last_child
92
+ when "firstElementChild"
93
+ first_element_child
94
+ when "lastElementChild"
95
+ last_element_child
96
+ when "textContent"
97
+ @__node__.text
98
+ end
99
+ end
100
+
101
+ def __js_call__(method, args)
102
+ case method
103
+ when "cloneNode"
104
+ deep = args.empty? ? false : !!args[0]
105
+ deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc)) : @document
106
+ .wrap_node(Parser.fragment("", owner_doc: @document.nokogiri_doc))
107
+ when "querySelector"
108
+ query_selector(args[0])
109
+ when "querySelectorAll"
110
+ query_selector_all(args[0])
111
+ when "getElementById"
112
+ get_element_by_id(args[0])
113
+ when "appendChild"
114
+ append_child(args[0])
115
+ else
116
+ nil
117
+ end
118
+ end
119
+
120
+ def extract_children
121
+ nodes = @__node__.children.to_a
122
+ nodes.each(&:unlink)
123
+ nodes
124
+ end
125
+
126
+ private
127
+
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
135
+
136
+ node.unlink if node.parent
137
+ [node]
138
+ end
139
+ end
140
+
141
+ def element_children
142
+ @__node__.element_children.each_with_object([]) do |node, out|
143
+ wrapped = @document.wrap_node(node)
144
+ out << wrapped if wrapped
145
+ end
146
+ end
147
+
148
+ # Fragments aren't part of the bubble chain; nil terminates
149
+ # bubbling at the boundary (shadow root, detached fragment, etc.).
150
+ def __event_parent__
151
+ nil
152
+ end
153
+ end
154
+
155
+ # CharacterData base — TextNode and CommentNode share the data /
156
+ # nodeValue / textContent API and `remove` / `cloneNode` semantics.
157
+ class CharacterDataNode
158
+ include Node
159
+
160
+ attr_reader :__node__
161
+
162
+ def initialize(document, nokogiri_node)
163
+ @document = document
164
+ @__node__ = nokogiri_node
165
+ end
166
+
167
+ # Snake_case facade (CRuby idiomatic)
168
+
169
+ def data
170
+ @__node__.content
171
+ end
172
+
173
+ def data=(value)
174
+ write_data(value)
175
+ end
176
+
177
+ def node_value
178
+ @__node__.content
179
+ end
180
+
181
+ def node_value=(value)
182
+ write_data(value)
183
+ end
184
+
185
+ def text_content
186
+ @__node__.content
187
+ end
188
+
189
+ def text_content=(value)
190
+ write_data(value)
191
+ end
192
+
193
+ def remove
194
+ @__node__.unlink
195
+ nil
196
+ end
197
+
198
+ def parent_node
199
+ @__node__.parent && @document.wrap_node(@__node__.parent)
200
+ end
201
+
202
+ def next_sibling
203
+ @__node__.next && @document.wrap_node(@__node__.next)
204
+ end
205
+
206
+ def previous_sibling
207
+ @__node__.previous && @document.wrap_node(@__node__.previous)
208
+ end
209
+
210
+ def [](key)
211
+ __js_get__(key.to_s)
212
+ end
213
+
214
+ def []=(key, value)
215
+ __js_set__(key.to_s, value)
216
+ end
217
+
218
+ def __js_get__(key)
219
+ case key
220
+ when "nodeType"
221
+ node_type
222
+ when "textContent"
223
+ @__node__.content
224
+ when "data"
225
+ @__node__.content
226
+ when "nodeValue"
227
+ @__node__.content
228
+ when "parentNode"
229
+ parent_node
230
+ when "nextSibling"
231
+ next_sibling
232
+ when "previousSibling"
233
+ previous_sibling
234
+ end
235
+ end
236
+
237
+ def __js_set__(key, value)
238
+ case key
239
+ when "textContent", "data", "nodeValue"
240
+ write_data(value)
241
+ end
242
+
243
+ nil
244
+ end
245
+
246
+ def __js_call__(method, _args)
247
+ case method
248
+ when "remove"
249
+ @__node__.unlink
250
+ nil
251
+ end
252
+ end
253
+
254
+ private
255
+
256
+ def write_data(value)
257
+ old = @__node__.content
258
+ @__node__.content = value.to_s
259
+ @document.notify_character_data_mutation(target_node: @__node__, old_value: old)
260
+ end
261
+ end
262
+
263
+ class TextNode < CharacterDataNode
264
+ def node_type
265
+ 3
266
+ end
267
+
268
+ def __js_call__(method, args)
269
+ case method
270
+ when "cloneNode"
271
+ @document.create_text_node(@__node__.text)
272
+ else
273
+ super
274
+ end
275
+ end
276
+ end
277
+
278
+ class CommentNode < CharacterDataNode
279
+ def node_type
280
+ 8
281
+ end
282
+
283
+ def __js_call__(method, args)
284
+ case method
285
+ when "cloneNode"
286
+ @document.create_comment(@__node__.content)
287
+ else
288
+ super
289
+ end
290
+ end
291
+ end
292
+
293
+ # (`LiveChildren` removed — `el.children` now returns a
294
+ # `Dommy::HTMLCollection` initialized with a re-evaluating block.)
295
+
296
+ class ClassList
297
+ include Enumerable
298
+
299
+ def initialize(element)
300
+ @element = element
301
+ end
302
+
303
+ def length
304
+ class_tokens.length
305
+ end
306
+
307
+ alias size length
308
+
309
+ def item(index)
310
+ class_tokens[index.to_i]
311
+ end
312
+
313
+ def value
314
+ @element.__node__["class"].to_s
315
+ end
316
+
317
+ def value=(new_value)
318
+ @element.set_attribute("class", new_value.to_s)
319
+ end
320
+
321
+ # Spec: contains() does NOT validate (no SyntaxError on empty).
322
+ def contains?(token)
323
+ class_tokens.include?(token.to_s)
324
+ end
325
+
326
+ def add(*tokens)
327
+ update_tokens { |existing| existing | normalize_tokens(tokens) }
328
+ nil
329
+ end
330
+
331
+ def remove(*tokens)
332
+ update_tokens { |existing| existing - normalize_tokens(tokens) }
333
+ nil
334
+ end
335
+
336
+ def replace(old_token, new_token)
337
+ old_s = validate_token(old_token)
338
+ new_s = validate_token(new_token)
339
+ tokens = class_tokens
340
+ idx = tokens.index(old_s)
341
+ return false unless idx
342
+
343
+ tokens[idx] = new_s
344
+ @element.set_attribute("class", tokens.uniq.join(" "))
345
+ true
346
+ end
347
+
348
+ def [](index)
349
+ item(index)
350
+ end
351
+
352
+ def each(&blk)
353
+ class_tokens.each(&blk)
354
+ end
355
+
356
+ def to_a
357
+ class_tokens.dup
358
+ end
359
+
360
+ def to_s
361
+ value
362
+ end
363
+
364
+ def __js_get__(key)
365
+ case key
366
+ when "length"
367
+ length
368
+ when "value"
369
+ value
370
+ else
371
+ if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
372
+ item(key.to_i)
373
+ end
374
+ end
375
+ end
376
+
377
+ def __js_set__(key, val)
378
+ case key
379
+ when "value"
380
+ self.value = val
381
+ end
382
+
383
+ nil
384
+ end
385
+
386
+ def __js_call__(method, args)
387
+ case method
388
+ when "add"
389
+ update_tokens { |tokens| tokens | normalize_tokens(args) }
390
+ nil
391
+ when "remove"
392
+ update_tokens { |tokens| tokens - normalize_tokens(args) }
393
+ nil
394
+ when "contains"
395
+ class_tokens.include?(args[0].to_s)
396
+ when "toggle"
397
+ toggle(args[0], args[1])
398
+ when "replace"
399
+ replace(args[0], args[1])
400
+ when "item"
401
+ item(args[0])
402
+ else
403
+ nil
404
+ end
405
+ end
406
+
407
+ private
408
+
409
+ def toggle(token, force)
410
+ name = validate_token(token)
411
+ present = class_tokens.include?(name)
412
+ if force.nil?
413
+ desired = !present
414
+ else
415
+ desired = !!force
416
+ end
417
+
418
+ update_tokens do |tokens|
419
+ desired ? (tokens | [name]) : (tokens - [name])
420
+ end
421
+
422
+ desired
423
+ end
424
+
425
+ # Spec: any empty-string argument throws SyntaxError; any token
426
+ # containing ASCII whitespace throws InvalidCharacterError. Applies
427
+ # to add / remove / replace / toggle.
428
+ 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
436
+ end
437
+
438
+ def validate_token(token)
439
+ s = token.to_s
440
+ raise DOMException::SyntaxError, "token is empty" if s.empty?
441
+ raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
442
+
443
+ s
444
+ end
445
+
446
+ def class_tokens
447
+ raw = @element.__node__["class"].to_s
448
+ raw.split(/\s+/).reject(&:empty?)
449
+ end
450
+
451
+ def update_tokens
452
+ 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
458
+ end
459
+ end
460
+
461
+ # `Element#dataset` proxy. `el.dataset.fooBar` reads / writes
462
+ # `data-foo-bar` per the HTMLOrForeignElement.dataset spec
463
+ # (camelCase ↔ kebab-case round-trip).
464
+ class DatasetMap
465
+ def initialize(element)
466
+ @element = element
467
+ end
468
+
469
+ def __js_get__(key)
470
+ @element.__node__[attr_name(key)]
471
+ end
472
+
473
+ def __js_set__(key, value)
474
+ @element.set_attribute(attr_name(key), value.to_s)
475
+ nil
476
+ end
477
+
478
+ def __js_call__(_method, _args)
479
+ nil
480
+ end
481
+
482
+ private
483
+
484
+ def attr_name(key)
485
+ "data-#{key.to_s.gsub(/[A-Z]/) { |m| "-#{m.downcase}" }}"
486
+ end
487
+ end
488
+
489
+ # Stub `DOMRect` for `getBoundingClientRect` — no layout engine,
490
+ # so all values are 0. Consumer code that uses these for *relative*
491
+ # positioning sees zeroed values; absolute layout assertions need
492
+ # the real browser.
493
+ class DOMRect
494
+ def initialize(x: 0, y: 0, width: 0, height: 0)
495
+ @x = x
496
+ @y = y
497
+ @width = width
498
+ @height = height
499
+ end
500
+
501
+ def __js_get__(key)
502
+ case key
503
+ when "x", "left"
504
+ @x
505
+ when "y", "top"
506
+ @y
507
+ when "width"
508
+ @width
509
+ when "height"
510
+ @height
511
+ when "right"
512
+ @x + @width
513
+ when "bottom"
514
+ @y + @height
515
+ end
516
+ end
517
+
518
+ def js_null?
519
+ false
520
+ end
521
+ end
522
+
523
+ class StyleDeclaration
524
+ include Enumerable
525
+
526
+ def initialize(element)
527
+ @element = element
528
+ end
529
+
530
+ # CSSStyleDeclaration interface: cssText round-trips the full
531
+ # `style` attribute. Setter parses semicolon-separated entries.
532
+ def css_text
533
+ properties.map { |k, v| "#{k}:#{v}" }.join(";")
534
+ end
535
+
536
+ def css_text=(value)
537
+ props = {}
538
+ value.to_s.split(";").each do |entry|
539
+ key, val = entry.split(":", 2)
540
+ next unless key && val
541
+
542
+ props[key.strip] = val.strip
543
+ end
544
+
545
+ write_properties(props)
546
+ end
547
+
548
+ def length
549
+ properties.size
550
+ end
551
+
552
+ # `style[0]` returns the property name at that index (matches
553
+ # `style.item(i)` in real DOM). String key form (`style["color"]`)
554
+ # is a convenience shortcut for `getPropertyValue`.
555
+ def [](key)
556
+ if key.is_a?(Integer)
557
+ properties.keys[key]
558
+ else
559
+ properties[key.to_s]
560
+ end
561
+ end
562
+
563
+ def []=(name, value)
564
+ set_property(name, value)
565
+ end
566
+
567
+ def each(&blk)
568
+ properties.keys.each(&blk)
569
+ end
570
+
571
+ # camelCase JS property accessors → kebab-case CSS property name.
572
+ # `style.backgroundColor = "red"` becomes `background-color: red`.
573
+ def method_missing(name, *args)
574
+ key = method_to_css_name(name)
575
+ if name.to_s.end_with?("=")
576
+ set_property(key, args.first)
577
+ elsif properties.key?(key)
578
+ properties[key]
579
+ else
580
+ ""
581
+ end
582
+ end
583
+
584
+ def respond_to_missing?(_name, _include_private = false)
585
+ true
586
+ end
587
+
588
+ def __js_get__(key)
589
+ case key
590
+ when "cssText"
591
+ css_text
592
+ when "length"
593
+ length
594
+ else
595
+ if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
596
+ self[key.to_i]
597
+ else
598
+ properties[method_to_css_name(key)]
599
+ end
600
+ end
601
+ end
602
+
603
+ def __js_set__(key, value)
604
+ case key
605
+ when "cssText"
606
+ self.css_text = value
607
+ else
608
+ set_property(method_to_css_name(key), value)
609
+ end
610
+
611
+ nil
612
+ end
613
+
614
+ def __js_call__(method, args)
615
+ case method
616
+ when "setProperty"
617
+ set_property(args[0], args[1])
618
+ when "removeProperty"
619
+ remove_property(args[0])
620
+ when "getPropertyValue"
621
+ properties[args[0].to_s]
622
+ when "item"
623
+ properties.keys[args[0].to_i]
624
+ else
625
+ nil
626
+ end
627
+ end
628
+
629
+ private
630
+
631
+ def method_to_css_name(name)
632
+ s = name.to_s.sub(/=\z/, "")
633
+ # snake_case (Ruby idiomatic) → kebab; camelCase (JS idiomatic) → kebab.
634
+ s.include?("_") ? s.tr("_", "-") : s.gsub(/[A-Z]/) { |m| "-#{m.downcase}" }
635
+ end
636
+
637
+ def set_property(name, value)
638
+ key = name.to_s
639
+ props = properties
640
+ if value.nil? || value.to_s.empty?
641
+ props.delete(key)
642
+ else
643
+ props[key] = value.to_s
644
+ end
645
+
646
+ write_properties(props)
647
+ nil
648
+ end
649
+
650
+ def remove_property(name)
651
+ key = name.to_s
652
+ props = properties
653
+ removed = props.delete(key)
654
+ write_properties(props)
655
+ removed
656
+ end
657
+
658
+ def properties
659
+ raw = @element.__node__["style"].to_s
660
+ raw.split(";").each_with_object({}) do |entry, out|
661
+ key, value = entry.split(":", 2)
662
+ next unless key && value
663
+
664
+ out[key.strip] = value.strip
665
+ end
666
+ end
667
+
668
+ def write_properties(props)
669
+ if props.empty?
670
+ @element.remove_attribute("style") if @element.__node__.key?("style")
671
+ else
672
+ @element.set_attribute("style", props.map { |k, v| "#{k}:#{v}" }.join(";"))
673
+ end
674
+ end
675
+ end
676
+
677
+ class Element
678
+ include EventTarget
679
+ include Node
680
+
681
+ attr_reader :__node__, :document
682
+
683
+ def initialize(document, nokogiri_node)
684
+ @document = document
685
+ @__node__ = nokogiri_node
686
+ @class_list = ClassList.new(self)
687
+ @style = StyleDeclaration.new(self)
688
+ @dataset = DatasetMap.new(self)
689
+ # `HTMLCollection` re-evaluates the child list on every
690
+ # property access so callers that capture `el[:children]` once
691
+ # see DOM mutations made between iterations — required by list
692
+ # reconciliation patterns that rely on the spec's live
693
+ # HTMLCollection semantics to detect already-positioned nodes.
694
+ @live_children = HTMLCollection.new do
695
+ @__node__.element_children.map { |n| @document.wrap_node(n) }.compact
696
+ end
697
+ end
698
+
699
+ # ----- Public Ruby API (snake_case) -----
700
+ #
701
+ # Mirrors HTMLElement DOM properties / methods in idiomatic Ruby
702
+ # form. The bridge protocol (`__js_get__` / `__js_call__`) routes
703
+ # camelCase JS names through these same accessors, so any fix here
704
+ # is visible in both views.
705
+
706
+ def text_content
707
+ @__node__.text
708
+ end
709
+
710
+ def text_content=(value)
711
+ __js_set__("textContent", value)
712
+ end
713
+
714
+ def inner_html
715
+ __js_get__("innerHTML")
716
+ end
717
+
718
+ def inner_html=(value)
719
+ __js_set__("innerHTML", value)
720
+ end
721
+
722
+ def tag_name
723
+ @__node__.name.upcase
724
+ end
725
+
726
+ def id
727
+ @__node__["id"].to_s
728
+ end
729
+
730
+ def id=(value)
731
+ set_attribute("id", value.to_s)
732
+ end
733
+
734
+ def class_name
735
+ @__node__["class"].to_s
736
+ end
737
+
738
+ def class_name=(value)
739
+ set_attribute("class", value.to_s)
740
+ end
741
+
742
+ def class_list
743
+ @class_list
744
+ end
745
+
746
+ def style
747
+ @style
748
+ end
749
+
750
+ def dataset
751
+ @dataset
752
+ end
753
+
754
+ def children
755
+ @live_children
756
+ end
757
+
758
+ def parent_element
759
+ @document.wrap_node(@__node__.parent) if @__node__.parent&.element?
760
+ end
761
+
762
+ alias parent parent_element
763
+
764
+ def parent_node
765
+ @__node__.parent && @document.wrap_node(@__node__.parent)
766
+ end
767
+
768
+ def first_element_child
769
+ @document.wrap_node(@__node__.element_children.first)
770
+ end
771
+
772
+ def last_element_child
773
+ @document.wrap_node(@__node__.element_children.last)
774
+ end
775
+
776
+ def first_child
777
+ @document.wrap_node(@__node__.children.first)
778
+ end
779
+
780
+ def last_child
781
+ @document.wrap_node(@__node__.children.last)
782
+ end
783
+
784
+ def child_element_count
785
+ @__node__.element_children.size
786
+ end
787
+
788
+ def child_nodes
789
+ NodeList.new(@__node__.children.map { |n| @document.wrap_node(n) }.compact)
790
+ end
791
+
792
+ # Live NodeList over this element's children. Reflects later
793
+ # mutations on every access.
794
+ def live_child_nodes
795
+ @live_child_nodes ||= LiveNodeList.new do
796
+ @__node__.children.map { |n| @document.wrap_node(n) }.compact
797
+ end
798
+ end
799
+
800
+ def has_child_nodes?
801
+ @__node__.children.any?
802
+ end
803
+
804
+ def has_attributes?
805
+ @__node__.attribute_nodes.any?
806
+ end
807
+
808
+ def next_sibling
809
+ @__node__.next && @document.wrap_node(@__node__.next)
810
+ end
811
+
812
+ def previous_sibling
813
+ @__node__.previous && @document.wrap_node(@__node__.previous)
814
+ end
815
+
816
+ def next_element_sibling
817
+ node = @__node__.next
818
+ node = node.next while node && !node.element?
819
+ node && @document.wrap_node(node)
820
+ end
821
+
822
+ def previous_element_sibling
823
+ node = @__node__.previous
824
+ node = node.previous while node && !node.element?
825
+ node && @document.wrap_node(node)
826
+ end
827
+
828
+ # Outer HTML — serializes this element and its subtree. Setter
829
+ # replaces this element in its parent with the parsed fragment.
830
+ def outer_html
831
+ @__node__.to_html
832
+ end
833
+
834
+ # Per WHATWG DOM Parsing:
835
+ # - parent is null (detached element) → return silently
836
+ # - parent is the Document (`<html>` element) → throw
837
+ # NoModificationAllowedError (can't replace the document
838
+ # element via this API)
839
+ # - otherwise, parse `html` as a fragment in the parent's
840
+ # context and replace this element with the parsed nodes
841
+ def outer_html=(html)
842
+ parent = @__node__.parent
843
+ return unless parent
844
+
845
+ if parent.is_a?(Nokogiri::XML::Document)
846
+ raise(
847
+ DOMException::NoModificationAllowedError,
848
+ "outerHTML setter not allowed on the document element"
849
+ )
850
+ end
851
+
852
+ fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
853
+ anchor = @__node__.next_sibling
854
+ removed = @__node__
855
+ new_nodes = fragment.children.to_a
856
+ @__node__.unlink
857
+ if anchor
858
+ new_nodes.reverse_each { |n| anchor.add_previous_sibling(n) }
859
+ else
860
+ new_nodes.each { |n| parent.add_child(n) }
861
+ end
862
+
863
+ @document.notify_child_list_mutation(target_node: parent, added_nodes: new_nodes, removed_nodes: [removed])
864
+ end
865
+
866
+ # `el.contains(other)` — true if `other` is `el` itself or any
867
+ # descendant. Per spec, returns false for null/non-Node.
868
+ def contains?(other)
869
+ return false unless other.respond_to?(:__node__)
870
+
871
+ other_node = other.__node__
872
+ return true if other_node == @__node__
873
+
874
+ Internal::NodeTraversal.ancestor_of?(@__node__, other_node)
875
+ end
876
+
877
+ # `el.getRootNode()` — returns the topmost ancestor (document,
878
+ # ShadowRoot, fragment, or self if detached). If the element lives
879
+ # inside a shadow tree, returns that ShadowRoot. Otherwise walks
880
+ # until we hit the Nokogiri Document (then returns the Document).
881
+ def root_node
882
+ sr = @document.__shadow_root_containing__(@__node__)
883
+ return sr if sr
884
+
885
+ current = @__node__
886
+ attached = false
887
+ loop do
888
+ parent = current.respond_to?(:parent) ? current.parent : nil
889
+ break unless parent
890
+ if parent.is_a?(Nokogiri::XML::Document)
891
+ attached = true
892
+ break
893
+ end
894
+
895
+ current = parent
896
+ end
897
+
898
+ return @document if attached
899
+
900
+ @document.wrap_node(current) || @document
901
+ end
902
+
903
+ alias get_root_node root_node
904
+
905
+ # Merge adjacent text node siblings and drop empty text nodes.
906
+ def normalize
907
+ @__node__.traverse do |node|
908
+ next unless node.text?
909
+ next if node.parent.nil?
910
+
911
+ if node.content == "" && node.parent
912
+ node.unlink
913
+ elsif node.next && node.next.text?
914
+ node.content = node.content + node.next.content
915
+ node.next.unlink
916
+ end
917
+ end
918
+
919
+ nil
920
+ end
921
+
922
+ def toggle_attribute(name, force = nil)
923
+ key = name.to_s.downcase
924
+ present = @__node__.key?(key)
925
+ desired = force.nil? ? !present : !!force
926
+ if desired
927
+ set_attribute(key, "") unless present
928
+ true
929
+ else
930
+ remove_attribute(key) if present
931
+ false
932
+ end
933
+ end
934
+
935
+ def matches?(selector)
936
+ return false if selector.nil? || selector.to_s.empty?
937
+
938
+ # `:scope` pseudo — match against this element itself.
939
+ sel = selector.to_s.gsub(":scope", "*:nth-last-child(n)")
940
+ matches_selector?(@__node__, sel)
941
+ end
942
+
943
+ def get_elements_by_class_name(name)
944
+ tokens = name.to_s.split(/\s+/).reject(&:empty?)
945
+ root = @__node__
946
+ doc = @document
947
+ HTMLCollection.new do
948
+ next [] if tokens.empty?
949
+
950
+ selector = tokens.map { |t| ".#{t}" }.join("")
951
+ root.css(selector).map { |n| doc.wrap_node(n) }.compact
952
+ end
953
+ end
954
+
955
+ def get_elements_by_tag_name(name)
956
+ n = name.to_s.downcase
957
+ root = @__node__
958
+ doc = @document
959
+ if n == "*"
960
+ HTMLCollection.new { root.css("*").map { |x| doc.wrap_node(x) }.compact }
961
+ else
962
+ HTMLCollection.new { root.css(n).map { |x| doc.wrap_node(x) }.compact }
963
+ end
964
+ end
965
+
966
+ # NamedNodeMap of attributes. Lazily allocated and re-used so
967
+ # `el.attributes === el.attributes` and `attr.ownerElement === el`.
968
+ def attributes
969
+ @attributes ||= NamedNodeMap.new(self)
970
+ end
971
+
972
+ def get_attribute_node(name)
973
+ attributes.get_named_item(name)
974
+ end
975
+
976
+ def set_attribute_node(attr)
977
+ attributes.set_named_item(attr)
978
+ end
979
+
980
+ def remove_attribute_node(attr)
981
+ return nil unless attr.respond_to?(:name)
982
+
983
+ attributes.remove_named_item(attr.name)
984
+ end
985
+
986
+ # HTML namespace constants — most HTML elements live in xhtml ns.
987
+ def namespace_uri
988
+ ns = @__node__.namespace
989
+ ns ? ns.href : "http://www.w3.org/1999/xhtml"
990
+ end
991
+
992
+ def local_name
993
+ @__node__.name.downcase
994
+ end
995
+
996
+ # `slot` and `role` are simple reflected string attributes —
997
+ # added as named accessors for happy-dom test parity.
998
+ def slot
999
+ @__node__["slot"].to_s
1000
+ end
1001
+
1002
+ def slot=(value)
1003
+ set_attribute("slot", value.to_s)
1004
+ end
1005
+
1006
+ def role
1007
+ @__node__["role"].to_s
1008
+ end
1009
+
1010
+ def role=(value)
1011
+ set_attribute("role", value.to_s)
1012
+ end
1013
+
1014
+ # `Node.baseURI` — resolves against the document's base URL, which
1015
+ # in turn honors the first `<base href>` element (see
1016
+ # `Document#base_uri`).
1017
+ def base_uri
1018
+ @document.base_uri
1019
+ end
1020
+
1021
+ def owner_document
1022
+ @document
1023
+ end
1024
+
1025
+ # Walks parents up to the Document (or false when the chain
1026
+ # dead-ends). Crosses ShadowRoot boundaries: a node inside an
1027
+ # open or closed shadow tree is connected iff its host is.
1028
+ def is_connected?
1029
+ current = @__node__
1030
+ seen = {}
1031
+ loop do
1032
+ # Guard against unexpected cycles in malformed trees.
1033
+ return false if seen[current.object_id]
1034
+
1035
+ seen[current.object_id] = true
1036
+
1037
+ parent = current.respond_to?(:parent) ? current.parent : nil
1038
+ return false unless parent
1039
+ return true if parent.is_a?(Nokogiri::XML::Document)
1040
+
1041
+ sr = @document.__shadow_root_for_fragment__(parent)
1042
+ if sr
1043
+ host = sr.host
1044
+ return false unless host
1045
+
1046
+ current = host.__node__
1047
+ else
1048
+ current = parent
1049
+ end
1050
+ end
1051
+ end
1052
+
1053
+ alias connected? is_connected?
1054
+
1055
+ # `focus()` / `blur()` — Dommy has no layout / real focus, but
1056
+ # tests rely on `document.activeElement` updating. Track the most
1057
+ # recently focused element on the document.
1058
+ def focus
1059
+ @document.__set_active_element__(self)
1060
+ nil
1061
+ end
1062
+
1063
+ def blur
1064
+ @document.__set_active_element__(nil)
1065
+ nil
1066
+ end
1067
+
1068
+ # Elements that may host a Shadow DOM tree per the HTML spec.
1069
+ # Custom-element-style names (containing "-") are also allowed.
1070
+ SHADOW_HOST_TAGS = %w[
1071
+ article
1072
+ aside
1073
+ blockquote
1074
+ body
1075
+ div
1076
+ footer
1077
+ h1
1078
+ h2
1079
+ h3
1080
+ h4
1081
+ h5
1082
+ h6
1083
+ header
1084
+ main
1085
+ nav
1086
+ p
1087
+ section
1088
+ span
1089
+ ]
1090
+ .freeze
1091
+
1092
+ # `el.attachShadow({ mode: "open" | "closed" })` — creates and
1093
+ # attaches a ShadowRoot. The shadow tree lives in its own
1094
+ # Nokogiri fragment and is invisible to the outer querySelector /
1095
+ # children chain. Per spec:
1096
+ # - the `mode` field is REQUIRED in the init dict
1097
+ # - only certain host element types are valid (see SHADOW_HOST_TAGS)
1098
+ # - re-attaching to an element that already has a shadow throws
1099
+ def attach_shadow(options = nil)
1100
+ tag = @__node__.name.downcase
1101
+ unless SHADOW_HOST_TAGS.include?(tag) || tag.include?("-")
1102
+ raise DOMException::NotSupportedError, "<#{tag}> cannot host a shadow root"
1103
+ end
1104
+
1105
+ raise DOMException::InvalidStateError, "Shadow root already attached" if @__shadow_root
1106
+
1107
+ opts = options.is_a?(Hash) ? options : {}
1108
+ mode_raw = opts.key?("mode") ? opts["mode"] : opts[:mode]
1109
+ raise TypeError, "attachShadow init dictionary requires 'mode'" if mode_raw.nil?
1110
+
1111
+ mode = mode_raw.to_s
1112
+ raise DOMException::SyntaxError, "mode must be 'open' or 'closed'" unless %w[open closed].include?(mode)
1113
+
1114
+ @__shadow_root = ShadowRoot.new(
1115
+ self,
1116
+ mode: mode,
1117
+ delegates_focus: opts["delegatesFocus"] || opts[:delegatesFocus] || false,
1118
+ slot_assignment: opts["slotAssignment"] || opts[:slotAssignment] || "named"
1119
+ )
1120
+ @__shadow_root
1121
+ end
1122
+
1123
+ # `el.shadowRoot` — returns the attached ShadowRoot only when
1124
+ # mode is "open"; closed shadows are hidden from external code.
1125
+ def shadow_root
1126
+ return nil unless @__shadow_root
1127
+ return nil if @__shadow_root.mode == "closed"
1128
+
1129
+ @__shadow_root
1130
+ end
1131
+
1132
+ # Internal — gives access to the shadow root regardless of mode.
1133
+ # Used by event composition / `composedPath()`.
1134
+ def __shadow_root__
1135
+ @__shadow_root
1136
+ end
1137
+
1138
+ # `el.insertAdjacentElement(position, element)` — DOM spec positions:
1139
+ # "beforebegin", "afterbegin", "beforeend", "afterend". Returns the
1140
+ # inserted element or nil if position has no anchor (root cases).
1141
+ def insert_adjacent_element(position, element)
1142
+ return nil unless element.respond_to?(:__node__)
1143
+
1144
+ case position.to_s
1145
+ when "beforebegin"
1146
+ return nil unless @__node__.parent
1147
+
1148
+ node = detach_for_insert(element)
1149
+ @__node__.add_previous_sibling(node)
1150
+ @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1151
+ when "afterbegin"
1152
+ node = detach_for_insert(element)
1153
+ first = @__node__.children.first
1154
+ first ? first.add_previous_sibling(node) : @__node__.add_child(node)
1155
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1156
+ when "beforeend"
1157
+ node = detach_for_insert(element)
1158
+ @__node__.add_child(node)
1159
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [node], removed_nodes: [])
1160
+ when "afterend"
1161
+ return nil unless @__node__.parent
1162
+
1163
+ node = detach_for_insert(element)
1164
+ @__node__.add_next_sibling(node)
1165
+ @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [node], removed_nodes: [])
1166
+ else
1167
+ return nil
1168
+ end
1169
+
1170
+ element
1171
+ end
1172
+
1173
+ def insert_adjacent_html(position, html)
1174
+ fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
1175
+ nodes = fragment.children.to_a
1176
+ case position.to_s
1177
+ when "beforebegin"
1178
+ return nil unless @__node__.parent
1179
+
1180
+ nodes.reverse_each { |n| @__node__.add_previous_sibling(n) }
1181
+ @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: nodes, removed_nodes: [])
1182
+ when "afterbegin"
1183
+ first = @__node__.children.first
1184
+ if first
1185
+ nodes.reverse_each { |n| first.add_previous_sibling(n) }
1186
+ else
1187
+ nodes.each { |n| @__node__.add_child(n) }
1188
+ end
1189
+
1190
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1191
+ when "beforeend"
1192
+ nodes.each { |n| @__node__.add_child(n) }
1193
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1194
+ when "afterend"
1195
+ return nil unless @__node__.parent
1196
+
1197
+ nodes.reverse_each { |n| @__node__.add_next_sibling(n) }
1198
+ @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: nodes, removed_nodes: [])
1199
+ end
1200
+
1201
+ nil
1202
+ end
1203
+
1204
+ def insert_adjacent_text(position, text)
1205
+ return nil if text.to_s.empty?
1206
+
1207
+ insert_adjacent_element(position, @document.create_text_node(text.to_s))
1208
+ end
1209
+
1210
+ # Convenience alias matching the DOM idiom `String(el)` → outerHTML.
1211
+ def to_s
1212
+ outer_html
1213
+ end
1214
+
1215
+ # Node type / NodeFilter bitmask constants — DOM Level 3 says these
1216
+ # are exposed on both the constructor and every instance. Defined
1217
+ # at the bottom of the class so subclasses inherit them too.
1218
+ ELEMENT_NODE = 1
1219
+ ATTRIBUTE_NODE = 2
1220
+ TEXT_NODE = 3
1221
+ CDATA_SECTION_NODE = 4
1222
+ PROCESSING_INSTRUCTION_NODE = 7
1223
+ COMMENT_NODE = 8
1224
+ DOCUMENT_NODE = 9
1225
+ DOCUMENT_TYPE_NODE = 10
1226
+ DOCUMENT_FRAGMENT_NODE = 11
1227
+
1228
+ DOCUMENT_POSITION_DISCONNECTED = 0x01
1229
+ DOCUMENT_POSITION_PRECEDING = 0x02
1230
+ DOCUMENT_POSITION_FOLLOWING = 0x04
1231
+ DOCUMENT_POSITION_CONTAINS = 0x08
1232
+ DOCUMENT_POSITION_CONTAINED_BY = 0x10
1233
+ DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 0x20
1234
+
1235
+ # Standard DOM compareDocumentPosition. Returns 0 for self, a
1236
+ # CONTAINS/CONTAINED_BY bitmask for ancestor/descendant pairs, or
1237
+ # PRECEDING/FOLLOWING for siblings (and DISCONNECTED for unrelated
1238
+ # nodes).
1239
+ def compare_document_position(other)
1240
+ return 0 if equal?(other)
1241
+ return DOCUMENT_POSITION_DISCONNECTED unless other.respond_to?(:__node__)
1242
+
1243
+ self_node = @__node__
1244
+ other_node = other.__node__
1245
+
1246
+ self_ancestors = ancestor_chain(self_node)
1247
+ other_ancestors = ancestor_chain(other_node)
1248
+
1249
+ common = nil
1250
+ self_ancestors.each do |a|
1251
+ if other_ancestors.include?(a)
1252
+ common = a
1253
+ break
1254
+ end
1255
+ end
1256
+
1257
+ unless common
1258
+ return DOCUMENT_POSITION_DISCONNECTED | DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC | DOCUMENT_POSITION_PRECEDING
1259
+ end
1260
+
1261
+ if common == self_node
1262
+ return DOCUMENT_POSITION_CONTAINED_BY | DOCUMENT_POSITION_FOLLOWING
1263
+ elsif common == other_node
1264
+ return DOCUMENT_POSITION_CONTAINS | DOCUMENT_POSITION_PRECEDING
1265
+ end
1266
+
1267
+ # Sibling-of-some-level case: compare the two branch points
1268
+ # under the common ancestor.
1269
+ self_branch = branch_under(common, self_ancestors)
1270
+ other_branch = branch_under(common, other_ancestors)
1271
+ common.children.each do |child|
1272
+ if child == self_branch
1273
+ return DOCUMENT_POSITION_FOLLOWING
1274
+ elsif child == other_branch
1275
+ return DOCUMENT_POSITION_PRECEDING
1276
+ end
1277
+ end
1278
+
1279
+ DOCUMENT_POSITION_DISCONNECTED
1280
+ end
1281
+
1282
+ # `Node.isSameNode(other)` — strict reference identity. The DOM
1283
+ # spec deprecates this in favor of `===`, but linkedom-style
1284
+ # tests still call it.
1285
+ def same_node?(other)
1286
+ equal?(other)
1287
+ end
1288
+
1289
+ # Structural equality — same nodeType, same tagName, same attribute
1290
+ # set, and recursively-equal children. Used by linkedom test
1291
+ # suite and standard DOM Node.isEqualNode.
1292
+ def equal_node?(other)
1293
+ return false unless other.is_a?(Element)
1294
+ return false unless @__node__.name == other.__node__.name
1295
+ return false unless attribute_signature == other.send(:attribute_signature)
1296
+ return false unless @__node__.children.size == other.__node__.children.size
1297
+
1298
+ @__node__.children.zip(other.__node__.children).all? do |a, b|
1299
+ wa = @document.wrap_node(a)
1300
+ wb = @document.wrap_node(b)
1301
+ wa.respond_to?(:equal_node?) ? wa.equal_node?(wb) : a.content == b.content
1302
+ end
1303
+ end
1304
+
1305
+ private
1306
+
1307
+ def ancestor_chain(node)
1308
+ chain = [node]
1309
+ Internal::NodeTraversal.each_ancestor(node) { |n| chain << n }
1310
+ chain
1311
+ end
1312
+
1313
+ def branch_under(common, chain)
1314
+ # Walk back along `chain` to find the entry whose parent is `common`.
1315
+ chain.each_with_index do |node, i|
1316
+ return node if i.zero? && node == common
1317
+ return node if node.respond_to?(:parent) && node.parent == common
1318
+ end
1319
+
1320
+ nil
1321
+ end
1322
+
1323
+ def attribute_signature
1324
+ @__node__.attribute_nodes.map { |a| [a.name, a.value] }.sort
1325
+ end
1326
+
1327
+ public
1328
+
1329
+ def remove
1330
+ __js_call__("remove", [])
1331
+ end
1332
+
1333
+ # ParentNode mixin methods — append / prepend / replaceChildren
1334
+ # take a mix of Node and String args (strings become text nodes).
1335
+
1336
+ def append(*args)
1337
+ append_nodes(args)
1338
+ end
1339
+
1340
+ def prepend(*args)
1341
+ prepend_nodes(args)
1342
+ end
1343
+
1344
+ def replace_children(*args)
1345
+ removed = @__node__.children.to_a
1346
+ removed.each(&:unlink)
1347
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1348
+ nodes.each { |n| @__node__.add_child(n) }
1349
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: removed)
1350
+ nil
1351
+ end
1352
+
1353
+ # ChildNode mixin — before / after / replaceWith with mixed args.
1354
+
1355
+ def before(*args)
1356
+ insert_adjacent(:before, args)
1357
+ end
1358
+
1359
+ def after(*args)
1360
+ insert_adjacent(:after, args)
1361
+ end
1362
+
1363
+ def replace_with_nodes(*args)
1364
+ replace_with(args)
1365
+ end
1366
+
1367
+ # `getInnerHTML()` — happy-dom alias for the `innerHTML` getter.
1368
+ # Real browsers add a `{ includeShadowRoots }` option which we
1369
+ # ignore (no Shadow DOM in Dommy).
1370
+ def get_inner_html(_options = nil)
1371
+ inner_html
1372
+ end
1373
+
1374
+ def get_html(_options = nil)
1375
+ inner_html
1376
+ end
1377
+
1378
+ def click
1379
+ __js_call__("click", [])
1380
+ end
1381
+
1382
+ # Ruby block-style listener (in addition to the (type, callable,
1383
+ # options) form inherited from EventTarget). Returns the resolved
1384
+ # listener so callers can pass it back to remove_event_listener.
1385
+ def on(type, &block)
1386
+ add_event_listener(type, block)
1387
+ block
1388
+ end
1389
+
1390
+ # `el[:foo]` / `el[:foo] = ...` bracket shortcut for the JS-style
1391
+ # property access pattern. Useful when porting browser-side code
1392
+ # to CRuby tests.
1393
+ def [](key)
1394
+ __js_get__(key.to_s)
1395
+ end
1396
+
1397
+ def []=(key, value)
1398
+ __js_set__(key.to_s, value)
1399
+ end
1400
+
1401
+ def __js_get__(key)
1402
+ case key
1403
+ when "nodeType"
1404
+ 1
1405
+ when "isConnected"
1406
+ is_connected?
1407
+ when "children"
1408
+ @live_children
1409
+ when "firstElementChild"
1410
+ @document.wrap_node(@__node__.element_children.first)
1411
+ when "parentElement", "parent"
1412
+ wrap_parent(@__node__.parent)
1413
+ when "parentNode"
1414
+ # `parentNode` is broader than `parentElement` — includes
1415
+ # DocumentFragment / Document parents too. Reconcilers use
1416
+ # this to find the host before calling replaceChild.
1417
+ @__node__.parent && @document.wrap_node(@__node__.parent)
1418
+ when "textContent"
1419
+ @__node__.text
1420
+ when "innerHTML"
1421
+ if @__node__.name == "template"
1422
+ @document.template_content_inner_html(self)
1423
+ else
1424
+ @__node__.inner_html
1425
+ end
1426
+
1427
+ when "tagName"
1428
+ @__node__.name.upcase
1429
+ when "classList"
1430
+ @class_list
1431
+ when "style"
1432
+ @style
1433
+ when "dataset"
1434
+ @dataset
1435
+ when "content"
1436
+ template_content
1437
+ when "className"
1438
+ # DOM reflects the `class` attribute as the `className` string
1439
+ # property (space-separated tokens, "" when absent).
1440
+ @__node__["class"].to_s
1441
+ when "id"
1442
+ @__node__["id"].to_s
1443
+ when "hidden", "disabled", "checked", "readOnly", "multiple", "required"
1444
+ # Boolean reflected properties — true iff the matching HTML
1445
+ # attribute is present. Real DOM normalizes attribute names to
1446
+ # lowercase, mapped here too (e.g. `readOnly` ↔ `readonly`).
1447
+ @__node__.key?(reflected_attr_name(key))
1448
+ when "value"
1449
+ # For form elements `value` is a property that defaults to the
1450
+ # `value` attribute. We don't model the property/attribute
1451
+ # split here — both reads and writes go through the attribute.
1452
+ @__node__["value"].to_s
1453
+ when "href"
1454
+ anchor_href
1455
+ when "attributes"
1456
+ attributes
1457
+ when "namespaceURI"
1458
+ namespace_uri
1459
+ when "localName"
1460
+ local_name
1461
+ when "nodeName"
1462
+ @__node__.name.upcase
1463
+ when "slot"
1464
+ slot
1465
+ when "role"
1466
+ role
1467
+ when "baseURI"
1468
+ base_uri
1469
+ when "shadowRoot"
1470
+ shadow_root
1471
+ when "ownerDocument"
1472
+ @document
1473
+ else
1474
+ # `el.onXxx` event handler property — returns the registered
1475
+ # callback (if any), or nil.
1476
+ if key.start_with?("on") && key.length > 2
1477
+ @on_handlers&.[](event_name_from_on(key))
1478
+ end
1479
+ end
1480
+ end
1481
+
1482
+ # Anchor / area `href` IDL attribute reflects the attribute resolved
1483
+ # against the document base URL (browser semantics). Routers rely on
1484
+ # this to compare origins and detect external links.
1485
+ def anchor_href
1486
+ raw = @__node__["href"]
1487
+ return "" if raw.nil?
1488
+
1489
+ win = @document.default_view
1490
+ base = win&.location ? win.location.href : ""
1491
+ URI.join(base, raw.to_s).to_s
1492
+ rescue URI::InvalidURIError, ArgumentError
1493
+ raw.to_s
1494
+ end
1495
+
1496
+ # Map a JS boolean property name to its underlying HTML attribute.
1497
+ # HTML attribute names are lowercase; the DOM property may be
1498
+ # camelCase (`readOnly` → `readonly`).
1499
+ def reflected_attr_name(key)
1500
+ {"readOnly" => "readonly"}.fetch(key, key)
1501
+ end
1502
+
1503
+ def __js_set__(key, value)
1504
+ case key
1505
+ when "textContent"
1506
+ @__node__.content = value.to_s
1507
+ when "innerHTML"
1508
+ removed = @__node__.children.to_a
1509
+ if @__node__.name == "template"
1510
+ # `<template>` content is invisible to outer selectors in
1511
+ # real DOM (it lives in a separate DocumentFragment exposed
1512
+ # via `[:content]`). Mirror that here so child placeholders
1513
+ # inside the template don't pollute outer queries.
1514
+ @document.attach_template_content(self, value.to_s)
1515
+ else
1516
+ @__node__.inner_html = value.to_s
1517
+ @document.migrate_template_descendants(@__node__)
1518
+ end
1519
+
1520
+ @document.notify_child_list_mutation(
1521
+ target_node: @__node__,
1522
+ added_nodes: @__node__.children.to_a,
1523
+ removed_nodes: removed
1524
+ )
1525
+ when "hidden", "disabled", "checked", "readOnly", "multiple", "required"
1526
+ # Boolean reflected property — funnel through set_attribute /
1527
+ # remove_attribute so MutationObserver attribute records fire.
1528
+ name = reflected_attr_name(key)
1529
+ if value
1530
+ set_attribute(name, "")
1531
+ elsif @__node__.key?(name)
1532
+ remove_attribute(name)
1533
+ end
1534
+
1535
+ when "className"
1536
+ set_attribute("class", value.to_s)
1537
+ when "id"
1538
+ set_attribute("id", value.to_s)
1539
+ when "value"
1540
+ set_attribute("value", value.to_s)
1541
+ when "slot"
1542
+ set_attribute("slot", value.to_s)
1543
+ when "role"
1544
+ set_attribute("role", value.to_s)
1545
+ else
1546
+ # `el.onXxx = fn` registers fn as a single named handler.
1547
+ # Setting to nil removes it. Mirrors HTMLElement IDL.
1548
+ if key.start_with?("on") && key.length > 2
1549
+ set_on_handler(event_name_from_on(key), value)
1550
+ else
1551
+ nil
1552
+ end
1553
+ end
1554
+ end
1555
+
1556
+ private
1557
+
1558
+ def event_name_from_on(key)
1559
+ key.to_s.sub(/\Aon/, "").downcase
1560
+ end
1561
+
1562
+ def set_on_handler(event_name, value)
1563
+ @on_handlers ||= {}
1564
+ previous = @on_handlers[event_name]
1565
+ remove_event_listener(event_name, previous) if previous
1566
+ if value
1567
+ add_event_listener(event_name, value)
1568
+ @on_handlers[event_name] = value
1569
+ else
1570
+ @on_handlers.delete(event_name)
1571
+ end
1572
+ end
1573
+
1574
+ public
1575
+
1576
+ def __js_call__(method, args)
1577
+ case method
1578
+ when "getAttribute"
1579
+ get_attribute(args[0])
1580
+ when "setAttribute"
1581
+ set_attribute(args[0], args[1])
1582
+ when "hasAttribute"
1583
+ has_attribute?(args[0])
1584
+ when "removeAttribute"
1585
+ remove_attribute(args[0])
1586
+ when "getAttributeNames"
1587
+ @__node__.attribute_nodes.map(&:name)
1588
+ when "closest"
1589
+ closest(args[0])
1590
+ when "querySelector"
1591
+ query_selector(args[0])
1592
+ when "querySelectorAll"
1593
+ query_selector_all(args[0])
1594
+ when "getElementsByClassName"
1595
+ get_elements_by_class_name(args[0])
1596
+ when "getElementsByTagName"
1597
+ get_elements_by_tag_name(args[0])
1598
+ when "insertAdjacentElement"
1599
+ insert_adjacent_element(args[0], args[1])
1600
+ when "insertAdjacentHTML"
1601
+ insert_adjacent_html(args[0], args[1])
1602
+ when "insertAdjacentText"
1603
+ insert_adjacent_text(args[0], args[1])
1604
+ when "toggleAttribute"
1605
+ toggle_attribute(args[0], args[1])
1606
+ when "matches"
1607
+ matches?(args[0])
1608
+ when "toString"
1609
+ to_s
1610
+ when "getAttributeNode"
1611
+ get_attribute_node(args[0])
1612
+ when "setAttributeNode"
1613
+ set_attribute_node(args[0])
1614
+ when "removeAttributeNode"
1615
+ remove_attribute_node(args[0])
1616
+ when "focus"
1617
+ focus
1618
+ when "blur"
1619
+ blur
1620
+ when "attachShadow"
1621
+ attach_shadow(args[0])
1622
+ when "addEventListener"
1623
+ add_event_listener(args[0], args[1], args[2])
1624
+ when "removeEventListener"
1625
+ remove_event_listener(args[0], args[1])
1626
+ when "dispatchEvent"
1627
+ dispatch_event(args[0])
1628
+ when "appendChild"
1629
+ append_child(args[0])
1630
+ when "insertBefore"
1631
+ insert_before(args[0], args[1])
1632
+ when "removeChild"
1633
+ remove_child(args[0])
1634
+ when "replaceChild"
1635
+ replace_child(args[0], args[1])
1636
+ when "cloneNode"
1637
+ clone_node(args[0])
1638
+ when "append"
1639
+ append_nodes(args)
1640
+ when "prepend"
1641
+ prepend_nodes(args)
1642
+ when "replaceChildren"
1643
+ replace_children(*args)
1644
+ when "before"
1645
+ insert_adjacent(:before, args)
1646
+ when "after"
1647
+ insert_adjacent(:after, args)
1648
+ when "getInnerHTML", "getHTML"
1649
+ inner_html
1650
+ when "remove"
1651
+ parent = @__node__.parent
1652
+ @__node__.unlink
1653
+ @document.notify_child_list_mutation(target_node: parent, added_nodes: [], removed_nodes: [@__node__]) if parent
1654
+ nil
1655
+ when "replaceWith"
1656
+ replace_with(args)
1657
+ when "click"
1658
+ dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
1659
+ when "getBoundingClientRect"
1660
+ DOMRect.new
1661
+ else
1662
+ nil
1663
+ end
1664
+ end
1665
+
1666
+ private
1667
+
1668
+ def element_children
1669
+ @__node__.element_children.each_with_object([]) do |node, out|
1670
+ wrapped = @document.wrap_node(node)
1671
+ out << wrapped if wrapped
1672
+ end
1673
+ end
1674
+
1675
+ def wrap_parent(node)
1676
+ @document.wrap_node(node)
1677
+ end
1678
+
1679
+ def __event_parent__
1680
+ parent_node = @__node__.parent
1681
+ # If our Nokogiri parent is a shadow tree's backing fragment,
1682
+ # the bubble path's next stop is the ShadowRoot itself — not
1683
+ # the bare Fragment wrapper. The ShadowRoot's __event_parent__
1684
+ # 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)
1687
+ return sr if sr
1688
+ end
1689
+
1690
+ parent = wrap_parent(parent_node)
1691
+ parent || @document
1692
+ end
1693
+
1694
+ def template_content
1695
+ return nil unless @__node__.name == "template"
1696
+
1697
+ @document.template_content_fragment(self)
1698
+ end
1699
+
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.
1704
+ def get_attribute(name)
1705
+ return nil if name.nil?
1706
+
1707
+ @__node__[name.to_s.downcase]
1708
+ end
1709
+
1710
+ def set_attribute(name, value)
1711
+ return nil if name.nil?
1712
+
1713
+ key = name.to_s.downcase
1714
+ old = @__node__[key]
1715
+ @__node__[key] = value.to_s
1716
+ @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1717
+ nil
1718
+ end
1719
+
1720
+ def has_attribute?(name)
1721
+ return false if name.nil?
1722
+
1723
+ @__node__.key?(name.to_s.downcase)
1724
+ end
1725
+
1726
+ def remove_attribute(name)
1727
+ return nil if name.nil?
1728
+
1729
+ key = name.to_s.downcase
1730
+ return nil unless @__node__.key?(key)
1731
+
1732
+ old = @__node__[key]
1733
+ @__node__.remove_attribute(key)
1734
+ @document.notify_attribute_mutation(target_node: @__node__, attribute_name: key, old_value: old)
1735
+ nil
1736
+ end
1737
+
1738
+ def closest(selector)
1739
+ return nil if selector.nil? || selector.to_s.empty?
1740
+
1741
+ node = @__node__
1742
+ while node&.element?
1743
+ return @document.wrap_node(node) if matches_selector?(node, selector.to_s)
1744
+
1745
+ node = node.parent
1746
+ end
1747
+
1748
+ nil
1749
+ end
1750
+
1751
+ def query_selector(selector)
1752
+ return nil if selector.nil? || selector.to_s.empty?
1753
+
1754
+ @document.wrap_node(@__node__.at_css(selector.to_s))
1755
+ end
1756
+
1757
+ def query_selector_all(selector)
1758
+ return NodeList.new if selector.nil? || selector.to_s.empty?
1759
+
1760
+ NodeList.new(@__node__.css(selector.to_s).map { |node| @document.wrap_node(node) }.compact)
1761
+ end
1762
+
1763
+ def append_child(child)
1764
+ check_hierarchy!(child)
1765
+ nodes = detach_dom_nodes(child)
1766
+ append_dom_nodes(nodes)
1767
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1768
+ child
1769
+ end
1770
+
1771
+ def insert_before(child, reference)
1772
+ check_hierarchy!(child)
1773
+ nodes = detach_dom_nodes(child)
1774
+ if reference.nil?
1775
+ append_dom_nodes(nodes)
1776
+ else
1777
+ ref_node = unwrap_dom_node(reference)
1778
+ if ref_node&.parent != @__node__
1779
+ # Per spec this should be a NotFoundError, but the legacy
1780
+ # behaviour of `appendChild` when reference is foreign is a
1781
+ # silent append. Preserve that for compatibility.
1782
+ append_dom_nodes(nodes)
1783
+ else
1784
+ nodes.reverse_each { |node| ref_node.add_previous_sibling(node) }
1785
+ end
1786
+ end
1787
+
1788
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1789
+ child
1790
+ end
1791
+
1792
+ def remove_child(child)
1793
+ node = unwrap_dom_node(child)
1794
+ unless node&.parent == @__node__
1795
+ raise DOMException::NotFoundError, "node is not a child of this element"
1796
+ end
1797
+
1798
+ node.unlink
1799
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [], removed_nodes: [node])
1800
+ child
1801
+ end
1802
+
1803
+ # `node.replaceChild(newChild, oldChild)` — required for
1804
+ # in-place item updates in list reconcilers. Inserts newChild
1805
+ # where oldChild was, then unlinks oldChild. Notifies
1806
+ # MutationObserver of both changes in one record so observers
1807
+ # see the swap atomically.
1808
+ def replace_child(new_child, old_child)
1809
+ old_node = unwrap_dom_node(old_child)
1810
+ return nil unless old_node&.parent == @__node__
1811
+
1812
+ new_nodes = detach_dom_nodes(new_child)
1813
+ new_nodes.reverse_each { |node| old_node.add_previous_sibling(node) }
1814
+ old_node.unlink
1815
+ @document.notify_child_list_mutation(
1816
+ target_node: @__node__,
1817
+ added_nodes: new_nodes,
1818
+ removed_nodes: [old_node]
1819
+ )
1820
+ old_child
1821
+ end
1822
+
1823
+ def clone_node(deep_arg)
1824
+ deep = !!deep_arg
1825
+ if deep
1826
+ @document.wrap_node(
1827
+ Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc).children.find(&:element?)
1828
+ )
1829
+ else
1830
+ clone = @document.create_element(@__node__.name)
1831
+ @__node__.attribute_nodes.each do |attr|
1832
+ clone.__js_call__("setAttribute", [attr.name, attr.value])
1833
+ end
1834
+
1835
+ clone
1836
+ end
1837
+ end
1838
+
1839
+ def append_nodes(args)
1840
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1841
+ append_dom_nodes(nodes)
1842
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1843
+ nil
1844
+ end
1845
+
1846
+ def prepend_nodes(args)
1847
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1848
+ anchor = @__node__.children.first
1849
+ if anchor
1850
+ nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
1851
+ else
1852
+ append_dom_nodes(nodes)
1853
+ end
1854
+
1855
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
1856
+ nil
1857
+ end
1858
+
1859
+ def insert_adjacent(side, args)
1860
+ parent = @__node__.parent
1861
+ return nil unless parent
1862
+
1863
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1864
+ case side
1865
+ when :before
1866
+ nodes.reverse_each { |node| @__node__.add_previous_sibling(node) }
1867
+ when :after
1868
+ anchor = @__node__.next_sibling
1869
+ if anchor
1870
+ nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
1871
+ else
1872
+ nodes.each { |node| parent.add_child(node) }
1873
+ end
1874
+ end
1875
+
1876
+ @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [])
1877
+ nil
1878
+ end
1879
+
1880
+ def replace_with(args)
1881
+ parent = @__node__.parent
1882
+ return nil unless parent
1883
+
1884
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
1885
+ removed = @__node__
1886
+ anchor = @__node__.next_sibling
1887
+ @__node__.unlink
1888
+ if anchor
1889
+ nodes.reverse_each { |node| anchor.add_previous_sibling(node) }
1890
+ else
1891
+ nodes.each { |node| parent.add_child(node) }
1892
+ end
1893
+
1894
+ @document.notify_child_list_mutation(target_node: parent, added_nodes: nodes, removed_nodes: [removed])
1895
+ nil
1896
+ end
1897
+
1898
+ def append_dom_nodes(nodes)
1899
+ nodes.each { |node| @__node__.add_child(node) }
1900
+ end
1901
+
1902
+ # Raise HierarchyRequestError when the proposed insertion would
1903
+ # produce a cycle (inserting an ancestor as a descendant of
1904
+ # itself). Strings and Fragments are always safe.
1905
+ def check_hierarchy!(child)
1906
+ return unless child.respond_to?(:__node__)
1907
+
1908
+ node = child.__node__
1909
+ return unless node.is_a?(Nokogiri::XML::Node)
1910
+
1911
+ if node == @__node__ || @__node__.ancestors.any? { |a| a == node }
1912
+ raise(
1913
+ DOMException::HierarchyRequestError,
1914
+ "Cannot insert a node as a descendant of itself"
1915
+ )
1916
+ end
1917
+ end
1918
+
1919
+ def detach_for_insert(value)
1920
+ detach_dom_nodes(value).first
1921
+ end
1922
+
1923
+ def detach_dom_nodes(value)
1924
+ case value
1925
+ when Element, TextNode, CommentNode
1926
+ node = value.__node__
1927
+ node.unlink if node.parent
1928
+ [node]
1929
+ when Fragment
1930
+ value.extract_children
1931
+ when String
1932
+ [@document.create_text_node(value).__node__]
1933
+ else
1934
+ node = unwrap_dom_node(value)
1935
+ return [] unless node
1936
+
1937
+ node.unlink if node.parent
1938
+ [node]
1939
+ end
1940
+ end
1941
+
1942
+ def unwrap_dom_node(value)
1943
+ return value.__node__ if value.respond_to?(:__node__)
1944
+
1945
+ nil
1946
+ end
1947
+
1948
+ def matches_selector?(node, selector)
1949
+ if node.respond_to?(:matches?)
1950
+ node.matches?(selector)
1951
+ else
1952
+ node.document.css(selector).any? { |candidate| candidate == node }
1953
+ end
1954
+ end
1955
+
1956
+ # Re-expose snake_case methods that the JS bridge dispatch routes
1957
+ # to. Defined as private originally so internal helpers (element_children,
1958
+ # detach_dom_nodes, etc.) stay encapsulated; CRuby users call these
1959
+ # as the public Ruby API.
1960
+ public(
1961
+ :get_attribute,
1962
+ :set_attribute,
1963
+ :has_attribute?,
1964
+ :remove_attribute,
1965
+ :append_child,
1966
+ :insert_before,
1967
+ :remove_child,
1968
+ :replace_child,
1969
+ :clone_node,
1970
+ :query_selector,
1971
+ :query_selector_all,
1972
+ :closest
1973
+ )
1974
+ end
1975
+ end