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.
- checksums.yaml +4 -4
- data/lib/dommy/attr.rb +46 -8
- data/lib/dommy/backend/makiri_adapter.rb +34 -0
- data/lib/dommy/backend.rb +29 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +239 -36
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/document.rb +525 -57
- data/lib/dommy/element.rb +479 -239
- data/lib/dommy/event.rb +296 -15
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +145 -21
- data/lib/dommy/html_elements.rb +1675 -266
- data/lib/dommy/interaction/driver.rb +155 -14
- data/lib/dommy/interaction/event_synthesis.rb +115 -7
- data/lib/dommy/interaction/field_interactor.rb +60 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/cascade.rb +44 -2
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/mutation_coordinator.rb +27 -7
- data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +37 -73
- data/lib/dommy/internal/selector_matcher.rb +89 -0
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +19 -4
- data/lib/dommy/js/host_bridge.rb +21 -0
- data/lib/dommy/js/host_runtime.js +926 -64
- data/lib/dommy/js/script_boot.rb +80 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +3 -4
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +180 -27
- data/lib/dommy/range.rb +15 -3
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +33 -0
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +18 -36
- data/lib/dommy/url.rb +4 -2
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +126 -7
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +2 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
num.to_i % (2**32)
|
|
362
442
|
end
|
|
363
443
|
|
|
364
444
|
def append_data(value)
|
|
365
|
-
write_data(@__node__.content + value
|
|
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
|
-
|
|
379
|
-
|
|
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 = [
|
|
382
|
-
write_data(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
|
|
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"
|
|
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
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
# Element
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
#
|
|
1225
|
-
#
|
|
1226
|
-
#
|
|
1227
|
-
# backend's `content
|
|
1228
|
-
#
|
|
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
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
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()`
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
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::
|
|
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
|
-
|
|
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
|
|
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
|
|
1923
|
+
case pos
|
|
1848
1924
|
when "beforebegin"
|
|
1849
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2083
|
+
child_node_before(args)
|
|
1997
2084
|
end
|
|
1998
2085
|
|
|
1999
2086
|
def after(*args)
|
|
2000
|
-
|
|
2087
|
+
child_node_after(args)
|
|
2001
2088
|
end
|
|
2002
2089
|
|
|
2003
2090
|
def replace_with_nodes(*args)
|
|
2004
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2037
|
-
# overrides these
|
|
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
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
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
|
-
|
|
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.
|
|
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] =
|
|
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
|
|
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
|
-
|
|
2863
|
+
child_node_before(args)
|
|
2633
2864
|
when "after"
|
|
2634
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2926
|
-
|
|
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
|
-
#
|
|
2930
|
-
|
|
2931
|
-
|
|
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
|
|
2935
|
-
@
|
|
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
|
-
|
|
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
|