dommy 0.8.0 → 0.9.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/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/element.rb
CHANGED
|
@@ -59,22 +59,23 @@ module Dommy
|
|
|
59
59
|
|
|
60
60
|
def query_selector(selector)
|
|
61
61
|
return nil if selector.nil?
|
|
62
|
-
Internal.
|
|
63
|
-
|
|
64
|
-
@document.wrap_node(@__node__.at_css(Internal.backend_safe_selector(selector.to_s)))
|
|
62
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
63
|
+
Internal::SelectorMatcher.query_first(self, ast, scope: self)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
66
|
def query_selector_all(selector)
|
|
68
67
|
return NodeList.new if selector.nil?
|
|
69
|
-
Internal.
|
|
70
|
-
|
|
71
|
-
NodeList.new(@__node__.css(Internal.backend_safe_selector(selector.to_s)).map { |n| @document.wrap_node(n) }.compact)
|
|
68
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
69
|
+
NodeList.new(Internal::SelectorMatcher.query(self, ast, scope: self))
|
|
72
70
|
end
|
|
73
71
|
|
|
74
72
|
def get_element_by_id(id)
|
|
75
|
-
return nil if id.nil?
|
|
73
|
+
return nil if id.nil? || id.to_s.empty?
|
|
76
74
|
|
|
77
|
-
|
|
75
|
+
# getElementById matches the `id` attribute literally, not as a CSS
|
|
76
|
+
# selector, so escape special characters (e.g. React `useId` `:rjm:`) to a
|
|
77
|
+
# valid id-selector ident — a raw "##{id}" would be an invalid selector.
|
|
78
|
+
@document.wrap_node(@__node__.at_css("##{Dommy::CSSNamespace.escape(id)}"))
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
def __js_get__(key)
|
|
@@ -101,6 +102,8 @@ module Dommy
|
|
|
101
102
|
@__node__.text
|
|
102
103
|
when "ownerDocument"
|
|
103
104
|
@document
|
|
105
|
+
else
|
|
106
|
+
Bridge::ABSENT
|
|
104
107
|
end
|
|
105
108
|
end
|
|
106
109
|
|
|
@@ -124,8 +127,8 @@ module Dommy
|
|
|
124
127
|
is_default_namespace(args[0])
|
|
125
128
|
when "cloneNode"
|
|
126
129
|
deep = args.empty? ? false : !!args[0]
|
|
127
|
-
deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.
|
|
128
|
-
.wrap_node(Parser.fragment("", owner_doc: @document.
|
|
130
|
+
deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.backend_doc)) : @document
|
|
131
|
+
.wrap_node(Parser.fragment("", owner_doc: @document.backend_doc))
|
|
129
132
|
when "querySelector"
|
|
130
133
|
query_selector(Internal.css_query_arg!(args))
|
|
131
134
|
when "querySelectorAll"
|
|
@@ -169,7 +172,12 @@ module Dommy
|
|
|
169
172
|
|
|
170
173
|
def extract_children
|
|
171
174
|
nodes = @__node__.children.to_a
|
|
175
|
+
return nodes if nodes.empty?
|
|
176
|
+
|
|
172
177
|
nodes.each(&:unlink)
|
|
178
|
+
# Inserting a DocumentFragment removes all its children first; the spec
|
|
179
|
+
# queues a single childList record on the fragment for that removal.
|
|
180
|
+
@document.notify_child_list_mutation(target_node: @__node__, added_nodes: [], removed_nodes: nodes)
|
|
173
181
|
nodes
|
|
174
182
|
end
|
|
175
183
|
|
|
@@ -207,7 +215,10 @@ module Dommy
|
|
|
207
215
|
return false unless other.respond_to?(:__dommy_backend_node__)
|
|
208
216
|
|
|
209
217
|
on = other.__dommy_backend_node__
|
|
210
|
-
|
|
218
|
+
# Walk parents rather than the backend's `ancestors`: Makiri omits a
|
|
219
|
+
# DocumentFragment parent from `ancestors`, so a fragment never appears
|
|
220
|
+
# to contain its own children. `parent` is consistent across backends.
|
|
221
|
+
on == @__node__ || Internal::NodeTraversal.ancestor_of?(@__node__, on)
|
|
211
222
|
end
|
|
212
223
|
|
|
213
224
|
def normalize
|
|
@@ -237,6 +248,10 @@ module Dommy
|
|
|
237
248
|
include Node
|
|
238
249
|
include EventTarget
|
|
239
250
|
|
|
251
|
+
# The owning Dommy document (as Element exposes), so cross-document adoption
|
|
252
|
+
# checks work for text/comment nodes too.
|
|
253
|
+
attr_reader :document
|
|
254
|
+
|
|
240
255
|
def __dommy_backend_node__ = @__node__
|
|
241
256
|
|
|
242
257
|
# EventTarget needs a parent for event propagation; a character-data node
|
|
@@ -255,7 +270,12 @@ module Dommy
|
|
|
255
270
|
rest = full[off..] || ""
|
|
256
271
|
write_data(full[0, off])
|
|
257
272
|
new_node = @document.create_text_node(rest)
|
|
258
|
-
|
|
273
|
+
if @__node__.parent
|
|
274
|
+
new_bn = new_node.__dommy_backend_node__
|
|
275
|
+
@__node__.add_next_sibling(new_bn)
|
|
276
|
+
# The new node is inserted right after self — a childList addition record.
|
|
277
|
+
@document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [new_bn], removed_nodes: [])
|
|
278
|
+
end
|
|
259
279
|
new_node
|
|
260
280
|
end
|
|
261
281
|
|
|
@@ -392,6 +412,8 @@ module Dommy
|
|
|
392
412
|
NodeList.new
|
|
393
413
|
when "firstChild", "lastChild"
|
|
394
414
|
nil
|
|
415
|
+
else
|
|
416
|
+
Bridge::ABSENT # unknown property: JS undefined, `in` absent
|
|
395
417
|
end
|
|
396
418
|
end
|
|
397
419
|
|
|
@@ -420,9 +442,14 @@ module Dommy
|
|
|
420
442
|
args[0].respond_to?(:__dommy_backend_node__) &&
|
|
421
443
|
args[0].__dommy_backend_node__ == @__node__
|
|
422
444
|
when "appendChild", "insertBefore"
|
|
423
|
-
#
|
|
445
|
+
# WebIDL coerces the Node argument first (null/non-Node → TypeError);
|
|
446
|
+
# only then does a leaf node reject any child with HierarchyRequestError.
|
|
447
|
+
raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
|
|
448
|
+
|
|
424
449
|
raise DOMException::HierarchyRequestError, "this node type does not support children"
|
|
425
450
|
when "removeChild", "replaceChild"
|
|
451
|
+
raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
|
|
452
|
+
|
|
426
453
|
raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
|
|
427
454
|
when "compareDocumentPosition"
|
|
428
455
|
compare_document_position(args[0])
|
|
@@ -598,6 +625,46 @@ module Dommy
|
|
|
598
625
|
end
|
|
599
626
|
end
|
|
600
627
|
|
|
628
|
+
# ProcessingInstruction (`<?target data?>`, nodeType 7) — CharacterData with a
|
|
629
|
+
# `target`. Backed by a real backend node (created via
|
|
630
|
+
# document.createProcessingInstruction), so it participates in the tree like
|
|
631
|
+
# Text/Comment: insertion, ChildNode methods, identity caching and
|
|
632
|
+
# serialization all come from the shared CharacterDataNode machinery.
|
|
633
|
+
class ProcessingInstructionNode < CharacterDataNode
|
|
634
|
+
def node_type
|
|
635
|
+
7
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# WHATWG: a ProcessingInstruction's nodeName is its target.
|
|
639
|
+
def node_name
|
|
640
|
+
@__node__.target
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def target
|
|
644
|
+
@__node__.target
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def __js_get__(key)
|
|
648
|
+
case key
|
|
649
|
+
when "target"
|
|
650
|
+
@__node__.target
|
|
651
|
+
else
|
|
652
|
+
super
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Own __js_call__ methods, on top of CharacterDataNode's.
|
|
657
|
+
js_methods %w[cloneNode]
|
|
658
|
+
def __js_call__(method, args)
|
|
659
|
+
case method
|
|
660
|
+
when "cloneNode"
|
|
661
|
+
@document.create_processing_instruction(@__node__.target, @__node__.content)
|
|
662
|
+
else
|
|
663
|
+
super
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
601
668
|
# (`LiveChildren` removed — `el.children` now returns a
|
|
602
669
|
# `Dommy::HTMLCollection` initialized with a re-evaluating block.)
|
|
603
670
|
|
|
@@ -637,6 +704,14 @@ module Dommy
|
|
|
637
704
|
class_tokens.include?(token.to_s)
|
|
638
705
|
end
|
|
639
706
|
|
|
707
|
+
# DOMTokenList membership. Defined explicitly (rather than inheriting
|
|
708
|
+
# Enumerable#include?, which re-iterates via #each) so a class-selector match
|
|
709
|
+
# is a single Array#include? over the cached tokens — the hot path under a
|
|
710
|
+
# querySelector-heavy SPA.
|
|
711
|
+
def include?(token)
|
|
712
|
+
class_tokens.include?(token.to_s)
|
|
713
|
+
end
|
|
714
|
+
|
|
640
715
|
def add(*tokens)
|
|
641
716
|
update_tokens { |existing| existing | normalize_tokens(tokens) }
|
|
642
717
|
nil
|
|
@@ -662,8 +737,12 @@ module Dommy
|
|
|
662
737
|
idx = tokens.index(old_s)
|
|
663
738
|
return false unless idx
|
|
664
739
|
|
|
665
|
-
|
|
666
|
-
|
|
740
|
+
# class_tokens returns the cached token array; dup before mutating so the
|
|
741
|
+
# in-place assignment can't corrupt the cache (whose key is still the old
|
|
742
|
+
# raw attribute string, which would then hand stale tokens to later reads).
|
|
743
|
+
updated = tokens.dup
|
|
744
|
+
updated[idx] = new_s
|
|
745
|
+
@element.set_attribute(@attribute, updated.uniq.join(" "))
|
|
667
746
|
true
|
|
668
747
|
end
|
|
669
748
|
|
|
@@ -698,6 +777,8 @@ module Dommy
|
|
|
698
777
|
i = key.to_i
|
|
699
778
|
token = i.negative? ? nil : class_tokens[i]
|
|
700
779
|
token.nil? ? Bridge::UNDEFINED : token
|
|
780
|
+
else
|
|
781
|
+
Bridge::ABSENT # unknown non-index property
|
|
701
782
|
end
|
|
702
783
|
end
|
|
703
784
|
end
|
|
@@ -790,7 +871,17 @@ module Dommy
|
|
|
790
871
|
# return the raw attribute. ASCII whitespace per the spec is space/tab/LF/FF/CR.
|
|
791
872
|
def class_tokens
|
|
792
873
|
raw = @element.__dommy_backend_node__[@attribute].to_s
|
|
793
|
-
raw
|
|
874
|
+
# Cache the parsed token list keyed by the raw attribute string: a class
|
|
875
|
+
# selector match re-reads this for every element on every querySelector,
|
|
876
|
+
# and the split/reject/uniq dominated heavy-SPA load profiles. The key is
|
|
877
|
+
# the raw value itself, so any change (add/remove/className=, or a direct
|
|
878
|
+
# backend mutation) yields a different key and transparently recomputes.
|
|
879
|
+
cached = @token_cache
|
|
880
|
+
return cached[1] if cached && cached[0] == raw
|
|
881
|
+
|
|
882
|
+
tokens = raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
|
|
883
|
+
@token_cache = [raw, tokens]
|
|
884
|
+
tokens
|
|
794
885
|
end
|
|
795
886
|
|
|
796
887
|
# DOMTokenList "update steps": serialize the (deduplicated) token set back to
|
|
@@ -814,7 +905,10 @@ module Dommy
|
|
|
814
905
|
end
|
|
815
906
|
|
|
816
907
|
def __js_get__(key)
|
|
817
|
-
|
|
908
|
+
# A missing data-* attribute reads as JS `undefined` (and `"foo" in dataset`
|
|
909
|
+
# is false), per DOMStringMap semantics.
|
|
910
|
+
value = @element.__dommy_backend_node__[attr_name(key)]
|
|
911
|
+
value.nil? ? Bridge::ABSENT : value
|
|
818
912
|
end
|
|
819
913
|
|
|
820
914
|
def __js_set__(key, value)
|
|
@@ -895,6 +989,8 @@ module Dommy
|
|
|
895
989
|
@x + @width
|
|
896
990
|
when "bottom"
|
|
897
991
|
@y + @height
|
|
992
|
+
else
|
|
993
|
+
Bridge::ABSENT
|
|
898
994
|
end
|
|
899
995
|
end
|
|
900
996
|
|
|
@@ -910,22 +1006,15 @@ module Dommy
|
|
|
910
1006
|
@element = element
|
|
911
1007
|
end
|
|
912
1008
|
|
|
913
|
-
# CSSStyleDeclaration interface: cssText
|
|
914
|
-
# `
|
|
1009
|
+
# CSSStyleDeclaration interface: cssText serializes the parsed declaration
|
|
1010
|
+
# block (`prop: value;` joined by spaces), dropping invalid declarations.
|
|
1011
|
+
# The setter reparses and rewrites the `style` attribute in that form.
|
|
915
1012
|
def css_text
|
|
916
|
-
properties
|
|
1013
|
+
serialize_properties(properties)
|
|
917
1014
|
end
|
|
918
1015
|
|
|
919
1016
|
def css_text=(value)
|
|
920
|
-
|
|
921
|
-
value.to_s.split(";").each do |entry|
|
|
922
|
-
key, val = entry.split(":", 2)
|
|
923
|
-
next unless key && val
|
|
924
|
-
|
|
925
|
-
props[key.strip] = val.strip
|
|
926
|
-
end
|
|
927
|
-
|
|
928
|
-
write_properties(props)
|
|
1017
|
+
write_properties(parse_declarations(value))
|
|
929
1018
|
end
|
|
930
1019
|
|
|
931
1020
|
def length
|
|
@@ -978,7 +1067,10 @@ module Dommy
|
|
|
978
1067
|
if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
|
|
979
1068
|
self[key.to_i]
|
|
980
1069
|
else
|
|
981
|
-
|
|
1070
|
+
# An unset CSS property reads as "" (per CSSStyleDeclaration), not nil —
|
|
1071
|
+
# `el.style.display` is "" until assigned, which `v-show` and other
|
|
1072
|
+
# display-toggling code compares against.
|
|
1073
|
+
properties[method_to_css_name(key)] || ""
|
|
982
1074
|
end
|
|
983
1075
|
end
|
|
984
1076
|
end
|
|
@@ -1041,20 +1133,48 @@ module Dommy
|
|
|
1041
1133
|
end
|
|
1042
1134
|
|
|
1043
1135
|
def properties
|
|
1044
|
-
|
|
1045
|
-
|
|
1136
|
+
parse_declarations(@element.__dommy_backend_node__["style"].to_s)
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# Parse a declaration block into an ordered { property => value } hash,
|
|
1140
|
+
# dropping declarations whose value is invalid (empty, or — like the second
|
|
1141
|
+
# colon in "color:: invalid" — containing a bare colon outside parentheses).
|
|
1142
|
+
def parse_declarations(str)
|
|
1143
|
+
str.to_s.split(";").each_with_object({}) do |entry, out|
|
|
1046
1144
|
key, value = entry.split(":", 2)
|
|
1047
1145
|
next unless key && value
|
|
1048
1146
|
|
|
1049
|
-
|
|
1147
|
+
name = key.strip
|
|
1148
|
+
val = value.strip
|
|
1149
|
+
next if name.empty? || !valid_declaration_value?(val)
|
|
1150
|
+
|
|
1151
|
+
out[name] = val
|
|
1152
|
+
end
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
def valid_declaration_value?(value)
|
|
1156
|
+
return false if value.empty?
|
|
1157
|
+
|
|
1158
|
+
depth = 0
|
|
1159
|
+
value.each_char do |c|
|
|
1160
|
+
case c
|
|
1161
|
+
when "(" then depth += 1
|
|
1162
|
+
when ")" then depth -= 1 if depth.positive?
|
|
1163
|
+
when ":" then return false if depth.zero?
|
|
1164
|
+
end
|
|
1050
1165
|
end
|
|
1166
|
+
true
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def serialize_properties(props)
|
|
1170
|
+
props.map { |k, v| "#{k}: #{v};" }.join(" ")
|
|
1051
1171
|
end
|
|
1052
1172
|
|
|
1053
1173
|
def write_properties(props)
|
|
1054
1174
|
if props.empty?
|
|
1055
1175
|
@element.remove_attribute("style") if @element.__dommy_backend_node__.key?("style")
|
|
1056
1176
|
else
|
|
1057
|
-
@element.set_attribute("style", props
|
|
1177
|
+
@element.set_attribute("style", serialize_properties(props))
|
|
1058
1178
|
end
|
|
1059
1179
|
end
|
|
1060
1180
|
end
|
|
@@ -1101,11 +1221,18 @@ module Dommy
|
|
|
1101
1221
|
end
|
|
1102
1222
|
|
|
1103
1223
|
def text_content=(value)
|
|
1104
|
-
#
|
|
1105
|
-
# non-empty
|
|
1106
|
-
# MutationObserver.
|
|
1224
|
+
# Setting textContent removes all existing children and, only for a
|
|
1225
|
+
# non-empty value, appends a single text node. Capture before/after to
|
|
1226
|
+
# feed MutationObserver. The empty case clears children directly: the
|
|
1227
|
+
# backend's `content=` is the parser's, and Makiri leaves an empty text
|
|
1228
|
+
# node behind for "" (Nokogiri and the DOM produce no node at all).
|
|
1107
1229
|
removed = @__node__.children.to_a
|
|
1108
|
-
|
|
1230
|
+
str = value.to_s
|
|
1231
|
+
if str.empty?
|
|
1232
|
+
removed.each(&:unlink)
|
|
1233
|
+
else
|
|
1234
|
+
@__node__.content = str
|
|
1235
|
+
end
|
|
1109
1236
|
added = @__node__.children.to_a
|
|
1110
1237
|
notify_child_list(added: added, removed: removed)
|
|
1111
1238
|
end
|
|
@@ -1127,10 +1254,28 @@ module Dommy
|
|
|
1127
1254
|
else
|
|
1128
1255
|
@__node__.inner_html = value.to_s
|
|
1129
1256
|
@document.migrate_template_descendants(@__node__)
|
|
1257
|
+
mark_fragment_scripts_started(@__node__.children.to_a)
|
|
1130
1258
|
end
|
|
1131
1259
|
notify_child_list(added: @__node__.children.to_a, removed: removed)
|
|
1132
1260
|
end
|
|
1133
1261
|
|
|
1262
|
+
# Per the HTML fragment parsing algorithm, a <script> created while parsing a
|
|
1263
|
+
# fragment (innerHTML / insertAdjacentHTML / outerHTML) has its "already
|
|
1264
|
+
# started" flag set, so it never executes when inserted. Flag every script in
|
|
1265
|
+
# the freshly parsed backend subtree before the connection notification —
|
|
1266
|
+
# which is what would otherwise run them — fires.
|
|
1267
|
+
def mark_fragment_scripts_started(backend_nodes)
|
|
1268
|
+
backend_nodes.each do |nk|
|
|
1269
|
+
next unless nk.respond_to?(:element?) && nk.element?
|
|
1270
|
+
|
|
1271
|
+
if nk.name == "script"
|
|
1272
|
+
wrapped = @document.wrap_node(nk)
|
|
1273
|
+
wrapped&.__internal_mark_script_already_started__ if wrapped.respond_to?(:__internal_mark_script_already_started__)
|
|
1274
|
+
end
|
|
1275
|
+
mark_fragment_scripts_started(nk.children.to_a) if nk.respond_to?(:children)
|
|
1276
|
+
end
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1134
1279
|
HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
|
|
1135
1280
|
|
|
1136
1281
|
# Record the namespace/prefix/localName an element was created with via
|
|
@@ -1158,6 +1303,16 @@ module Dommy
|
|
|
1158
1303
|
@__ns_prefix
|
|
1159
1304
|
end
|
|
1160
1305
|
|
|
1306
|
+
# The [namespace, prefix, local_name] explicitly assigned via createElementNS,
|
|
1307
|
+
# or nil when the element wasn't created with an explicit namespace. Lets the
|
|
1308
|
+
# XML serializer recover element-namespace info the makiri backend (lexbor,
|
|
1309
|
+
# HTML-only) doesn't retain.
|
|
1310
|
+
def __internal_created_namespace__
|
|
1311
|
+
return nil unless @__ns_qname
|
|
1312
|
+
|
|
1313
|
+
[@__ns_uri, @__ns_prefix, @__ns_local]
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1161
1316
|
def id
|
|
1162
1317
|
@__node__["id"].to_s
|
|
1163
1318
|
end
|
|
@@ -1315,6 +1470,7 @@ module Dommy
|
|
|
1315
1470
|
anchor = @__node__.next_sibling
|
|
1316
1471
|
removed = @__node__
|
|
1317
1472
|
new_nodes = fragment.children.to_a
|
|
1473
|
+
mark_fragment_scripts_started(new_nodes)
|
|
1318
1474
|
@__node__.unlink
|
|
1319
1475
|
if anchor
|
|
1320
1476
|
new_nodes.reverse_each { |n| anchor.add_previous_sibling(n) }
|
|
@@ -1340,9 +1496,18 @@ module Dommy
|
|
|
1340
1496
|
# ShadowRoot, fragment, or self if detached). If the element lives
|
|
1341
1497
|
# inside a shadow tree, returns that ShadowRoot. Otherwise walks
|
|
1342
1498
|
# until we hit the Nokogiri Document (then returns the Document).
|
|
1343
|
-
def root_node
|
|
1499
|
+
def root_node(options = nil)
|
|
1500
|
+
composed = options.is_a?(Hash) && EventTarget.js_truthy?(options.key?("composed") ? options["composed"] : options[:composed])
|
|
1344
1501
|
sr = @document.__internal_shadow_root_containing__(@__node__)
|
|
1345
|
-
|
|
1502
|
+
if sr
|
|
1503
|
+
# Default: the shadow root is the root. `composed: true` is
|
|
1504
|
+
# shadow-including — cross the boundary and continue from the host, so
|
|
1505
|
+
# the topmost document is returned.
|
|
1506
|
+
return sr unless composed
|
|
1507
|
+
return sr.host.root_node({"composed" => true}) if sr.host.respond_to?(:root_node)
|
|
1508
|
+
|
|
1509
|
+
return sr
|
|
1510
|
+
end
|
|
1346
1511
|
|
|
1347
1512
|
current = @__node__
|
|
1348
1513
|
attached = false
|
|
@@ -1365,17 +1530,33 @@ module Dommy
|
|
|
1365
1530
|
alias get_root_node root_node
|
|
1366
1531
|
|
|
1367
1532
|
# Merge adjacent text node siblings and drop empty text nodes.
|
|
1533
|
+
# WHATWG Node.normalize: drop empty Text nodes and merge each run of
|
|
1534
|
+
# contiguous Text nodes into the first, firing the matching mutation records
|
|
1535
|
+
# (childList for every removed node, characterData for the merged data).
|
|
1368
1536
|
def normalize
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
1378
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) }
|
|
1379
1560
|
end
|
|
1380
1561
|
|
|
1381
1562
|
nil
|
|
@@ -1398,11 +1579,8 @@ module Dommy
|
|
|
1398
1579
|
|
|
1399
1580
|
def matches?(selector)
|
|
1400
1581
|
return false if selector.nil?
|
|
1401
|
-
Internal.
|
|
1402
|
-
|
|
1403
|
-
# `:scope` pseudo — match against this element itself.
|
|
1404
|
-
sel = Internal.backend_safe_selector(selector.to_s).gsub(":scope", "*:nth-last-child(n)")
|
|
1405
|
-
matches_selector?(@__node__, sel)
|
|
1582
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
1583
|
+
Internal::SelectorMatcher.matches?(self, ast, scope: self)
|
|
1406
1584
|
end
|
|
1407
1585
|
|
|
1408
1586
|
def get_elements_by_class_name(name)
|
|
@@ -1476,6 +1654,26 @@ module Dommy
|
|
|
1476
1654
|
set_attribute("slot", value.to_s)
|
|
1477
1655
|
end
|
|
1478
1656
|
|
|
1657
|
+
# `assignedSlot` — for a slottable (a direct light-DOM child of a shadow
|
|
1658
|
+
# host), the `<slot>` in the host's *open* shadow tree it composes into,
|
|
1659
|
+
# else null. Per the spec's "open flag", a closed shadow tree always
|
|
1660
|
+
# returns null (mirrors `Element#shadowRoot` being null when closed).
|
|
1661
|
+
def assigned_slot
|
|
1662
|
+
parent = @__node__.parent
|
|
1663
|
+
return nil unless parent.respond_to?(:element?) && parent.element?
|
|
1664
|
+
|
|
1665
|
+
host = @document.wrap_node(parent)
|
|
1666
|
+
return nil unless host.respond_to?(:shadow_root)
|
|
1667
|
+
|
|
1668
|
+
sr = host.shadow_root
|
|
1669
|
+
return nil unless sr
|
|
1670
|
+
|
|
1671
|
+
slot_name = @__node__.element? ? @__node__["slot"].to_s : ""
|
|
1672
|
+
sr.query_selector_all("slot").find do |slot|
|
|
1673
|
+
(slot.respond_to?(:name) ? slot.name.to_s : "") == slot_name
|
|
1674
|
+
end
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1479
1677
|
def role
|
|
1480
1678
|
@__node__["role"].to_s
|
|
1481
1679
|
end
|
|
@@ -1484,6 +1682,38 @@ module Dommy
|
|
|
1484
1682
|
set_attribute("role", value.to_s)
|
|
1485
1683
|
end
|
|
1486
1684
|
|
|
1685
|
+
# The WAI-ARIA computed role (what `getByRole` / WPT's get_computed_role
|
|
1686
|
+
# report): an explicit valid `role` attribute, else the implicit HTML role.
|
|
1687
|
+
def computed_role
|
|
1688
|
+
Internal::AriaRole.compute(self)
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
# The WAI-ARIA accessible name (WPT's get_computed_label): aria-labelledby /
|
|
1692
|
+
# aria-label / native label / name-from-content / title.
|
|
1693
|
+
def computed_label
|
|
1694
|
+
Internal::AccessibleName.compute(self)
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
# The WAI-ARIA accessible description: aria-describedby / aria-description /
|
|
1698
|
+
# title (title only when not already used as the accessible name).
|
|
1699
|
+
def computed_description
|
|
1700
|
+
Internal::AccessibleDescription.compute(self)
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
# The accessibility tree rooted at this element (a synthetic root whose
|
|
1704
|
+
# children are this element's accessible nodes). See
|
|
1705
|
+
# Internal::AccessibilityTree.
|
|
1706
|
+
def accessibility_tree
|
|
1707
|
+
Internal::AccessibilityTree.build(self)
|
|
1708
|
+
end
|
|
1709
|
+
alias_method :aria_tree, :accessibility_tree
|
|
1710
|
+
|
|
1711
|
+
# A Playwright-compatible ARIA snapshot (indented YAML outline) of this
|
|
1712
|
+
# element's accessibility subtree.
|
|
1713
|
+
def aria_snapshot
|
|
1714
|
+
Internal::AriaSnapshot.serialize(accessibility_tree)
|
|
1715
|
+
end
|
|
1716
|
+
|
|
1487
1717
|
# `Node.baseURI` — resolves against the document's base URL, which
|
|
1488
1718
|
# in turn honors the first `<base href>` element (see
|
|
1489
1719
|
# `Document#base_uri`).
|
|
@@ -1503,9 +1733,9 @@ module Dommy
|
|
|
1503
1733
|
seen = {}
|
|
1504
1734
|
loop do
|
|
1505
1735
|
# Guard against unexpected cycles in malformed trees.
|
|
1506
|
-
return false if seen[current
|
|
1736
|
+
return false if seen[Backend.identity_key(current)]
|
|
1507
1737
|
|
|
1508
|
-
seen[current
|
|
1738
|
+
seen[Backend.identity_key(current)] = true
|
|
1509
1739
|
|
|
1510
1740
|
parent = current.respond_to?(:parent) ? current.parent : nil
|
|
1511
1741
|
return false unless parent
|
|
@@ -1652,6 +1882,7 @@ module Dommy
|
|
|
1652
1882
|
|
|
1653
1883
|
fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
|
|
1654
1884
|
nodes = fragment.children.to_a
|
|
1885
|
+
mark_fragment_scripts_started(nodes)
|
|
1655
1886
|
# `add_previous_sibling` inserts immediately before the anchor, so a forward
|
|
1656
1887
|
# walk preserves document order; `add_next_sibling` inserts immediately
|
|
1657
1888
|
# after, so afterend walks in reverse to keep order.
|
|
@@ -1784,21 +2015,75 @@ module Dommy
|
|
|
1784
2015
|
inner_html
|
|
1785
2016
|
end
|
|
1786
2017
|
|
|
2018
|
+
# `click()` runs the HTML activation behavior around the dispatched event:
|
|
2019
|
+
# pre-click activation may change state (e.g. toggle a checkbox), the click
|
|
2020
|
+
# is dispatched, and then either the activation behavior runs (not canceled)
|
|
2021
|
+
# or the pre-click state is restored (default prevented). Elements with no
|
|
2022
|
+
# activation behavior (the default) just dispatch the event.
|
|
1787
2023
|
def click
|
|
1788
|
-
|
|
2024
|
+
pre = pre_click_activation_state
|
|
2025
|
+
not_canceled = dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
|
|
2026
|
+
return not_canceled if pre.nil?
|
|
2027
|
+
|
|
2028
|
+
if not_canceled
|
|
2029
|
+
run_post_click_activation(pre)
|
|
2030
|
+
else
|
|
2031
|
+
restore_pre_click_activation(pre)
|
|
2032
|
+
end
|
|
2033
|
+
not_canceled
|
|
2034
|
+
end
|
|
2035
|
+
|
|
2036
|
+
# Activation-behavior hooks. The default element has none; HTMLInputElement
|
|
2037
|
+
# overrides these for checkbox/radio.
|
|
2038
|
+
def pre_click_activation_state
|
|
2039
|
+
nil
|
|
1789
2040
|
end
|
|
1790
2041
|
|
|
2042
|
+
def run_post_click_activation(_state); end
|
|
2043
|
+
|
|
2044
|
+
def restore_pre_click_activation(_state); end
|
|
2045
|
+
|
|
1791
2046
|
def get_attribute_names
|
|
1792
2047
|
Backend.attribute_nodes(@__node__).map(&:name)
|
|
1793
2048
|
end
|
|
1794
2049
|
|
|
1795
|
-
# No layout engine
|
|
2050
|
+
# No real layout engine. By default geometry getters return zeroed rects;
|
|
2051
|
+
# when the window opts into approximate geometry (window.approximate_layout)
|
|
2052
|
+
# they return non-zero estimates from a cheap pseudo-layout so a site that
|
|
2053
|
+
# treats an all-zero rect as "the DOM is broken" can proceed.
|
|
1796
2054
|
def get_bounding_client_rect
|
|
1797
|
-
DOMRect.new
|
|
2055
|
+
approximate_layout? ? DOMRect.new(**__internal_approx_box) : DOMRect.new
|
|
2056
|
+
end
|
|
2057
|
+
|
|
2058
|
+
def approximate_layout? = !!@document&.default_view&.approximate_layout
|
|
2059
|
+
|
|
2060
|
+
# Estimate {x, y, width, height} (CSS px) without laying out the page: block
|
|
2061
|
+
# elements fill the viewport width; inline elements are sized to their text;
|
|
2062
|
+
# height is the wrapped line count × a nominal line height. Position is the
|
|
2063
|
+
# origin (we don't position elements). Used only when approximate_layout?.
|
|
2064
|
+
INLINE_TAGS = %w[a span b i em strong small code label abbr cite q sub sup time mark u s
|
|
2065
|
+
tt var samp kbd bdi bdo wbr big font nobr].freeze
|
|
2066
|
+
APPROX_CHAR_PX = 8
|
|
2067
|
+
APPROX_LINE_PX = 20
|
|
2068
|
+
|
|
2069
|
+
def __internal_approx_box
|
|
2070
|
+
viewport = @document&.default_view&.inner_width.to_i
|
|
2071
|
+
viewport = 1280 if viewport <= 0
|
|
2072
|
+
text = text_content.to_s
|
|
2073
|
+
content_px = text.length * APPROX_CHAR_PX
|
|
2074
|
+
if INLINE_TAGS.include?(local_name.to_s.downcase)
|
|
2075
|
+
{x: 0, y: 0, width: [content_px, viewport].min, height: text.empty? ? 0 : APPROX_LINE_PX}
|
|
2076
|
+
else
|
|
2077
|
+
lines = text.empty? ? 0 : [(content_px.to_f / viewport).ceil, 1].max
|
|
2078
|
+
{x: 0, y: 0, width: viewport, height: lines * APPROX_LINE_PX}
|
|
2079
|
+
end
|
|
1798
2080
|
end
|
|
1799
2081
|
|
|
1800
2082
|
def get_client_rects
|
|
1801
|
-
[]
|
|
2083
|
+
return [] unless approximate_layout?
|
|
2084
|
+
|
|
2085
|
+
box = __internal_approx_box
|
|
2086
|
+
box[:width].positive? || box[:height].positive? ? [DOMRect.new(**box)] : []
|
|
1802
2087
|
end
|
|
1803
2088
|
|
|
1804
2089
|
def request_fullscreen
|
|
@@ -1849,22 +2134,19 @@ module Dommy
|
|
|
1849
2134
|
1
|
|
1850
2135
|
when "isConnected"
|
|
1851
2136
|
is_connected?
|
|
1852
|
-
when
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
"scrollWidth",
|
|
1856
|
-
"scrollHeight",
|
|
1857
|
-
"clientWidth",
|
|
1858
|
-
"clientHeight",
|
|
1859
|
-
"clientTop",
|
|
1860
|
-
"clientLeft",
|
|
1861
|
-
"offsetWidth",
|
|
1862
|
-
"offsetHeight",
|
|
1863
|
-
"offsetTop",
|
|
1864
|
-
"offsetLeft"
|
|
1865
|
-
# No layout engine — zeroed values match what real browsers
|
|
1866
|
-
# report for hidden / pre-paint elements.
|
|
2137
|
+
when "scrollTop", "scrollLeft", "clientTop", "clientLeft", "offsetTop", "offsetLeft"
|
|
2138
|
+
# Position-ish metrics: 0 (we never lay elements out in the page), as a
|
|
2139
|
+
# real browser reports for hidden / pre-paint elements.
|
|
1867
2140
|
0
|
|
2141
|
+
when "clientWidth", "clientHeight", "scrollWidth", "scrollHeight", "offsetWidth", "offsetHeight"
|
|
2142
|
+
# Size metrics: 0 by default; a best-effort estimate when the window opts
|
|
2143
|
+
# into approximate geometry (see #get_bounding_client_rect).
|
|
2144
|
+
if approximate_layout?
|
|
2145
|
+
box = __internal_approx_box
|
|
2146
|
+
key.end_with?("Width") ? box[:width] : box[:height]
|
|
2147
|
+
else
|
|
2148
|
+
0
|
|
2149
|
+
end
|
|
1868
2150
|
when "offsetParent"
|
|
1869
2151
|
nil
|
|
1870
2152
|
when "popover"
|
|
@@ -1960,6 +2242,8 @@ module Dommy
|
|
|
1960
2242
|
base_uri
|
|
1961
2243
|
when "shadowRoot"
|
|
1962
2244
|
shadow_root
|
|
2245
|
+
when "assignedSlot"
|
|
2246
|
+
assigned_slot
|
|
1963
2247
|
when "ownerDocument"
|
|
1964
2248
|
@document
|
|
1965
2249
|
else
|
|
@@ -1978,6 +2262,18 @@ module Dommy
|
|
|
1978
2262
|
elsif key.start_with?("on") && key.length > 2
|
|
1979
2263
|
# `el.onXxx` event handler property — the registered callback or nil.
|
|
1980
2264
|
@on_handlers&.[](event_name_from_on(key))
|
|
2265
|
+
elsif key.start_with?("_") || key.include?("$")
|
|
2266
|
+
# A framework-private expando key (React stores per-node state under
|
|
2267
|
+
# keys like `__reactListeners$<id>` and feature-detects it with
|
|
2268
|
+
# `node[key] === undefined`). Real DOM property names never use `_`/`$`,
|
|
2269
|
+
# so reporting these *absent* (undefined value, `in` false) is correct
|
|
2270
|
+
# JS and doesn't touch real DOM reflection (which WPT pins to null).
|
|
2271
|
+
Bridge::ABSENT
|
|
2272
|
+
else
|
|
2273
|
+
# A genuinely-unknown element property: JS `undefined`, `in` false.
|
|
2274
|
+
# (Reflected / ARIA / on* IDL attributes are handled above and keep
|
|
2275
|
+
# their nullable-DOMString null semantics.)
|
|
2276
|
+
Bridge::ABSENT
|
|
1981
2277
|
end
|
|
1982
2278
|
end
|
|
1983
2279
|
end
|
|
@@ -2159,6 +2455,12 @@ module Dommy
|
|
|
2159
2455
|
remove_attribute(name)
|
|
2160
2456
|
end
|
|
2161
2457
|
|
|
2458
|
+
when "style"
|
|
2459
|
+
# WHATWG [PutForwards=cssText]: `el.style = "..."` forwards to
|
|
2460
|
+
# `el.style.cssText`, reparsing and rewriting the `style` attribute.
|
|
2461
|
+
# Handling it here stops the bridge from stashing a string expando that
|
|
2462
|
+
# would shadow the CSSStyleDeclaration getter.
|
|
2463
|
+
@style.css_text = value.nil? ? "" : value.to_s
|
|
2162
2464
|
when "className"
|
|
2163
2465
|
set_attribute("class", value.to_s)
|
|
2164
2466
|
when "classList"
|
|
@@ -2210,9 +2512,16 @@ module Dommy
|
|
|
2210
2512
|
scrollTo scrollBy requestFullscreen showPopover hidePopover togglePopover isEqualNode
|
|
2211
2513
|
hasChildNodes hasAttributes getRootNode normalize contains
|
|
2212
2514
|
compareDocumentPosition isSameNode lookupNamespaceURI lookupPrefix isDefaultNamespace
|
|
2515
|
+
__internal_computed_role__ __internal_computed_label__ __internal_computed_description__
|
|
2213
2516
|
]
|
|
2214
2517
|
def __js_call__(method, args)
|
|
2215
2518
|
case method
|
|
2519
|
+
when "__internal_computed_role__"
|
|
2520
|
+
computed_role
|
|
2521
|
+
when "__internal_computed_label__"
|
|
2522
|
+
computed_label
|
|
2523
|
+
when "__internal_computed_description__"
|
|
2524
|
+
computed_description
|
|
2216
2525
|
when "hasChildNodes"
|
|
2217
2526
|
has_child_nodes?
|
|
2218
2527
|
when "hasAttributes"
|
|
@@ -2254,7 +2563,7 @@ module Dommy
|
|
|
2254
2563
|
when "getElementsByTagName"
|
|
2255
2564
|
get_elements_by_tag_name(args[0])
|
|
2256
2565
|
when "getRootNode"
|
|
2257
|
-
get_root_node
|
|
2566
|
+
get_root_node(args[0])
|
|
2258
2567
|
when "normalize"
|
|
2259
2568
|
normalize
|
|
2260
2569
|
when "insertAdjacentElement"
|
|
@@ -2443,24 +2752,8 @@ module Dommy
|
|
|
2443
2752
|
|
|
2444
2753
|
def closest(selector)
|
|
2445
2754
|
return nil if selector.nil?
|
|
2446
|
-
Internal.
|
|
2447
|
-
|
|
2448
|
-
# Elements matching the selector (scoped to this element, so `:scope`
|
|
2449
|
-
# resolves here), then return the nearest inclusive ancestor among them.
|
|
2450
|
-
handler = Internal.scoped_pseudo_handlers(@__node__)
|
|
2451
|
-
safe = Internal.backend_safe_selector(selector.to_s)
|
|
2452
|
-
matched = with_selector_errors(selector) do
|
|
2453
|
-
@document.nokogiri_doc.css(safe, handler).map(&:pointer_id)
|
|
2454
|
-
end
|
|
2455
|
-
|
|
2456
|
-
node = @__node__
|
|
2457
|
-
while node&.element?
|
|
2458
|
-
return @document.wrap_node(node) if matched.include?(node.pointer_id)
|
|
2459
|
-
|
|
2460
|
-
node = node.parent
|
|
2461
|
-
end
|
|
2462
|
-
|
|
2463
|
-
nil
|
|
2755
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
2756
|
+
Internal::SelectorMatcher.closest(self, ast)
|
|
2464
2757
|
end
|
|
2465
2758
|
|
|
2466
2759
|
# Map Nokogiri's selector errors to spec behavior:
|
|
@@ -2468,16 +2761,8 @@ module Dommy
|
|
|
2468
2761
|
# syntactically invalid → SyntaxError (querySelector/closest must throw);
|
|
2469
2762
|
# - an "Unregistered function" means a valid pseudo Nokogiri compiled but
|
|
2470
2763
|
# can't evaluate (`:hover`, `:invalid`, …) → degrade to matching nothing.
|
|
2471
|
-
def with_selector_errors(selector)
|
|
2472
|
-
|
|
2473
|
-
rescue ::StandardError => e
|
|
2474
|
-
return [] if e.message.include?("Unregistered function")
|
|
2475
|
-
|
|
2476
|
-
if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e.message.include?("unexpected")
|
|
2477
|
-
raise DOMException::SyntaxError, "'#{selector}' is not a valid selector."
|
|
2478
|
-
end
|
|
2479
|
-
|
|
2480
|
-
raise
|
|
2764
|
+
def with_selector_errors(selector, &block)
|
|
2765
|
+
Internal.with_selector_errors(selector, &block)
|
|
2481
2766
|
end
|
|
2482
2767
|
|
|
2483
2768
|
# Web Animations: start an animation on this element.
|
|
@@ -2504,36 +2789,32 @@ module Dommy
|
|
|
2504
2789
|
return nil if selector.nil?
|
|
2505
2790
|
# The empty string is not a valid selector (an explicit DOMString "" is a
|
|
2506
2791
|
# SyntaxError; `null` coerces to "null" and is handled above as nil).
|
|
2507
|
-
|
|
2792
|
+
sel = selector.to_s
|
|
2793
|
+
doc = owner_document
|
|
2794
|
+
key = [object_id, :first, sel]
|
|
2795
|
+
if doc && (hit = doc.__internal_scoped_query_get(key))
|
|
2796
|
+
return hit.first # [result] tuple — distinguishes a cached nil match from a miss
|
|
2797
|
+
end
|
|
2508
2798
|
|
|
2509
|
-
|
|
2799
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
2800
|
+
result = Internal::SelectorMatcher.query_first(self, ast, scope: self)
|
|
2801
|
+
doc&.__internal_scoped_query_set(key, [result])
|
|
2802
|
+
result
|
|
2510
2803
|
end
|
|
2511
2804
|
|
|
2512
2805
|
def query_selector_all(selector)
|
|
2513
2806
|
return NodeList.new if selector.nil?
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
# Run a CSS query rooted at this element. A `:scope` selector must resolve to
|
|
2520
|
-
# this element, but Nokogiri scopes `el.css` to descendants (`.//`), which
|
|
2521
|
-
# excludes the element itself — so for `:scope` queries we evaluate against
|
|
2522
|
-
# the whole document (where this element IS reachable) and restrict the
|
|
2523
|
-
# results to this element's own subtree.
|
|
2524
|
-
def scoped_query(sel)
|
|
2525
|
-
sel = Internal.backend_safe_selector(sel)
|
|
2526
|
-
handler = Internal.scoped_pseudo_handlers(@__node__)
|
|
2527
|
-
with_selector_errors(sel) do
|
|
2528
|
-
if sel.include?(":scope")
|
|
2529
|
-
self_id = @__node__.pointer_id
|
|
2530
|
-
@document.nokogiri_doc.css(sel, handler).select do |n|
|
|
2531
|
-
n.ancestors.any? { |a| a.pointer_id == self_id }
|
|
2532
|
-
end
|
|
2533
|
-
else
|
|
2534
|
-
@__node__.css(sel, handler)
|
|
2535
|
-
end
|
|
2807
|
+
sel = selector.to_s
|
|
2808
|
+
doc = owner_document
|
|
2809
|
+
key = [object_id, :all, sel]
|
|
2810
|
+
if doc && (hit = doc.__internal_scoped_query_get(key))
|
|
2811
|
+
return NodeList.new(hit) # NodeList.new copies, so the cached array is never aliased
|
|
2536
2812
|
end
|
|
2813
|
+
|
|
2814
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
2815
|
+
matches = Internal::SelectorMatcher.query(self, ast, scope: self)
|
|
2816
|
+
doc&.__internal_scoped_query_set(key, matches)
|
|
2817
|
+
NodeList.new(matches)
|
|
2537
2818
|
end
|
|
2538
2819
|
|
|
2539
2820
|
# XPath queries scoped to this element, returning wrapped nodes.
|
|
@@ -2552,20 +2833,21 @@ module Dommy
|
|
|
2552
2833
|
end
|
|
2553
2834
|
|
|
2554
2835
|
def insert_before(child, reference)
|
|
2555
|
-
|
|
2836
|
+
coerce_node_argument!(child)
|
|
2837
|
+
# WHATWG: if the reference child is the node being inserted, the reference
|
|
2838
|
+
# becomes that node's next sibling, so "insert x before x" doesn't move x.
|
|
2839
|
+
reference = wrapped_next_sibling(reference) if same_wrapped_node?(reference, child)
|
|
2840
|
+
ensure_pre_insertion_validity!(child, reference)
|
|
2556
2841
|
nodes = detach_dom_nodes(child)
|
|
2557
|
-
if reference.nil?
|
|
2842
|
+
if reference.nil? || (defined?(Bridge::UNDEFINED) && reference.equal?(Bridge::UNDEFINED))
|
|
2558
2843
|
append_dom_nodes(nodes)
|
|
2559
2844
|
else
|
|
2845
|
+
# The reference is guaranteed (by validity) to be a child here. Insert in
|
|
2846
|
+
# order before it: each new node becomes its immediate previous sibling,
|
|
2847
|
+
# so forward iteration yields the original order (reverse would flip a
|
|
2848
|
+
# multi-node fragment).
|
|
2560
2849
|
ref_node = unwrap_dom_node(reference)
|
|
2561
|
-
|
|
2562
|
-
# Per spec this should be a NotFoundError, but the legacy
|
|
2563
|
-
# behaviour of `appendChild` when reference is foreign is a
|
|
2564
|
-
# silent append. Preserve that for compatibility.
|
|
2565
|
-
append_dom_nodes(nodes)
|
|
2566
|
-
else
|
|
2567
|
-
nodes.reverse_each { |node| ref_node.add_previous_sibling(node) }
|
|
2568
|
-
end
|
|
2850
|
+
nodes.each { |node| ref_node.add_previous_sibling(node) }
|
|
2569
2851
|
end
|
|
2570
2852
|
|
|
2571
2853
|
notify_child_list(added: nodes)
|
|
@@ -2573,6 +2855,7 @@ module Dommy
|
|
|
2573
2855
|
end
|
|
2574
2856
|
|
|
2575
2857
|
def remove_child(child)
|
|
2858
|
+
coerce_node_argument!(child)
|
|
2576
2859
|
node = unwrap_dom_node(child)
|
|
2577
2860
|
unless node&.parent == @__node__
|
|
2578
2861
|
raise DOMException::NotFoundError, "node is not a child of this element"
|
|
@@ -2588,26 +2871,46 @@ module Dommy
|
|
|
2588
2871
|
# MutationObserver of both changes in one record so observers
|
|
2589
2872
|
# see the swap atomically.
|
|
2590
2873
|
def replace_child(new_child, old_child)
|
|
2874
|
+
coerce_node_argument!(new_child)
|
|
2875
|
+
coerce_node_argument!(old_child)
|
|
2876
|
+
# replaceChild shares the pre-insertion checks (ancestor, node type,
|
|
2877
|
+
# doctype placement); the reference child here is old_child, so step 3
|
|
2878
|
+
# also enforces that it is actually a child (NotFoundError otherwise).
|
|
2879
|
+
ensure_pre_insertion_validity!(new_child, old_child)
|
|
2591
2880
|
old_node = unwrap_dom_node(old_child)
|
|
2592
|
-
return nil unless old_node&.parent == @__node__
|
|
2593
2881
|
|
|
2882
|
+
# Capture the insertion point (old's next sibling) before detaching the new
|
|
2883
|
+
# child, which may itself be old (replaceChild(x, x)) or old's sibling.
|
|
2884
|
+
anchor = old_node.next_sibling
|
|
2594
2885
|
new_nodes = detach_dom_nodes(new_child)
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2886
|
+
anchor = nil if anchor && anchor.parent != @__node__
|
|
2887
|
+
|
|
2888
|
+
# detach_dom_nodes already removed old when new_child === old_child; only
|
|
2889
|
+
# unlink (and record the removal) when old is still attached.
|
|
2890
|
+
removed = []
|
|
2891
|
+
if old_node.parent == @__node__
|
|
2892
|
+
old_node.unlink
|
|
2893
|
+
removed = [old_node]
|
|
2894
|
+
end
|
|
2895
|
+
|
|
2896
|
+
if anchor
|
|
2897
|
+
new_nodes.each { |node| anchor.add_previous_sibling(node) }
|
|
2898
|
+
else
|
|
2899
|
+
new_nodes.each { |node| @__node__.add_child(node) }
|
|
2900
|
+
end
|
|
2901
|
+
notify_child_list(added: new_nodes, removed: removed)
|
|
2598
2902
|
old_child
|
|
2599
2903
|
end
|
|
2600
2904
|
|
|
2601
2905
|
def clone_node(deep_arg)
|
|
2602
|
-
# Copy the node in place via
|
|
2603
|
-
# a fragment: the HTML fragment parser unwraps `<body>` /
|
|
2604
|
-
# `<html>`, so cloning a body
|
|
2605
|
-
# (which broke Turbo's snapshot cache — it clones the body and
|
|
2606
|
-
# via documentElement.replaceChild on back/forward).
|
|
2607
|
-
# element's namespace and attributes (createElement would
|
|
2608
|
-
# namespace)
|
|
2609
|
-
copy = @__node__
|
|
2610
|
-
copy.children.each(&:unlink) unless deep_arg
|
|
2906
|
+
# Copy the node in place via the backend's deep clone, NOT by re-parsing
|
|
2907
|
+
# to_html as a fragment: the HTML fragment parser unwraps `<body>` /
|
|
2908
|
+
# `<head>` / `<html>`, so cloning a body would produce its children, not a
|
|
2909
|
+
# body element (which broke Turbo's snapshot cache — it clones the body and
|
|
2910
|
+
# restores it via documentElement.replaceChild on back/forward). The clone
|
|
2911
|
+
# preserves the element's namespace and attributes (createElement would
|
|
2912
|
+
# lose the namespace).
|
|
2913
|
+
copy = Backend.clone_node(@__node__, deep: deep_arg)
|
|
2611
2914
|
@document.wrap_node(copy)
|
|
2612
2915
|
end
|
|
2613
2916
|
|
|
@@ -2760,6 +3063,20 @@ module Dommy
|
|
|
2760
3063
|
detach_dom_nodes(value).first
|
|
2761
3064
|
end
|
|
2762
3065
|
|
|
3066
|
+
# Whether two wrapped values back the same backend node (used to detect
|
|
3067
|
+
# `insertBefore(x, x)`).
|
|
3068
|
+
def same_wrapped_node?(a, b)
|
|
3069
|
+
an = a.respond_to?(:__dommy_backend_node__) ? a.__dommy_backend_node__ : nil
|
|
3070
|
+
bn = b.respond_to?(:__dommy_backend_node__) ? b.__dommy_backend_node__ : nil
|
|
3071
|
+
!an.nil? && an == bn
|
|
3072
|
+
end
|
|
3073
|
+
|
|
3074
|
+
# The wrapped next sibling of a wrapped reference node (nil at end of list).
|
|
3075
|
+
def wrapped_next_sibling(reference)
|
|
3076
|
+
nk = reference.respond_to?(:__dommy_backend_node__) ? reference.__dommy_backend_node__&.next : nil
|
|
3077
|
+
nk && @document.wrap_node(nk)
|
|
3078
|
+
end
|
|
3079
|
+
|
|
2763
3080
|
def unwrap_dom_node(value)
|
|
2764
3081
|
return value.__dommy_backend_node__ if value.respond_to?(:__dommy_backend_node__)
|
|
2765
3082
|
|
|
@@ -2767,11 +3084,41 @@ module Dommy
|
|
|
2767
3084
|
end
|
|
2768
3085
|
|
|
2769
3086
|
def matches_selector?(node, selector)
|
|
2770
|
-
if node.
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3087
|
+
return false if node.nil?
|
|
3088
|
+
|
|
3089
|
+
# A valid pseudo the backend can't evaluate (`:active`, `:invalid`, …)
|
|
3090
|
+
# degrades to not-matching ([] from the rescue) — the same policy as
|
|
3091
|
+
# the query methods.
|
|
3092
|
+
result = with_selector_errors(selector) { matches_selector_uncaught?(node, selector) }
|
|
3093
|
+
result == [] ? false : result
|
|
3094
|
+
end
|
|
3095
|
+
|
|
3096
|
+
def matches_selector_uncaught?(node, selector)
|
|
3097
|
+
return node.document.css(selector).any? { |candidate| candidate == node } unless node.respond_to?(:matches?)
|
|
3098
|
+
|
|
3099
|
+
# A detached node (no parent) breaks Nokogiri's `matches?`, which evaluates
|
|
3100
|
+
# `ancestors.last.search(selector)` — `ancestors.last` is nil with no
|
|
3101
|
+
# ancestors. matches() ignores connectivity (a disconnected element still
|
|
3102
|
+
# matches a selector it satisfies — e.g. Stimulus checks a just-removed
|
|
3103
|
+
# outlet element), so give a parentless node a transient fragment root,
|
|
3104
|
+
# then restore its detached state.
|
|
3105
|
+
if node.respond_to?(:parent) && node.parent.nil? &&
|
|
3106
|
+
node.respond_to?(:document) && node.document.respond_to?(:fragment)
|
|
3107
|
+
return matches_detached_node?(node, selector)
|
|
2774
3108
|
end
|
|
3109
|
+
|
|
3110
|
+
node.matches?(selector)
|
|
3111
|
+
end
|
|
3112
|
+
|
|
3113
|
+
# Match a parentless node by wrapping it in a throwaway fragment so the
|
|
3114
|
+
# backend's `matches?` has an ancestor root, then unlinking to leave the
|
|
3115
|
+
# node detached (and its parentNode unchanged) as it was. `fragment("")`
|
|
3116
|
+
# (not the no-arg form) is backend-agnostic — Makiri's takes a source string.
|
|
3117
|
+
def matches_detached_node?(node, selector)
|
|
3118
|
+
node.document.fragment("").add_child(node)
|
|
3119
|
+
node.matches?(selector)
|
|
3120
|
+
ensure
|
|
3121
|
+
node.unlink
|
|
2775
3122
|
end
|
|
2776
3123
|
|
|
2777
3124
|
# No real layout — record the scroll request so tests can assert it.
|