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
|
@@ -113,12 +113,11 @@ module Dommy
|
|
|
113
113
|
# event sequence so JS click handlers run.
|
|
114
114
|
def click(selector)
|
|
115
115
|
element = find(selector)
|
|
116
|
-
prevented
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
submit_owning_form(element) unless prevented
|
|
116
|
+
# An un-prevented click runs the element's activation behavior — a submit
|
|
117
|
+
# button submits its owning form (SubmitButtonActivation), an anchor
|
|
118
|
+
# follows its href — so `click "button[type=submit]"` / `click "a"` behave
|
|
119
|
+
# like a real click with no driver-level special-casing.
|
|
120
|
+
EventSynthesis.click(element)
|
|
122
121
|
after_interaction
|
|
123
122
|
element
|
|
124
123
|
end
|
|
@@ -165,6 +164,87 @@ module Dommy
|
|
|
165
164
|
result
|
|
166
165
|
end
|
|
167
166
|
|
|
167
|
+
# Type into the element matching `selector` (focusing it first). Each
|
|
168
|
+
# key is a Symbol for a named key (:enter, :arrow_down, :escape, … see
|
|
169
|
+
# EventSynthesis::NAMED_KEYS) or a String typed character by character
|
|
170
|
+
# (keydown -> keypress -> beforeinput -> insertion + input -> keyup).
|
|
171
|
+
# Default actions mirror a browser's: characters insert into a text
|
|
172
|
+
# field, Backspace deletes, Enter inserts a newline in a textarea and
|
|
173
|
+
# triggers the owning form's implicit submission elsewhere. Preventing
|
|
174
|
+
# keydown / keypress / beforeinput suppresses the default action, so
|
|
175
|
+
# SPA keyboard handlers behave as they would in a browser.
|
|
176
|
+
#
|
|
177
|
+
# browser.send_keys "#q", :arrow_down, :enter
|
|
178
|
+
# browser.send_keys "#q", "hello"
|
|
179
|
+
def send_keys(selector, *keys)
|
|
180
|
+
send_keys_to(find(selector), *keys)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Element-based variant of #send_keys, for hosts that already hold the
|
|
184
|
+
# element (capybara-dommy's Node#send_keys). Same semantics.
|
|
185
|
+
def send_keys_to(element, *keys)
|
|
186
|
+
EventSynthesis.focus(element)
|
|
187
|
+
keys.each { |key| dispatch_send_key(element, key) }
|
|
188
|
+
after_interaction
|
|
189
|
+
element
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Compose text through an IME, with the canonical (Chrome-like) event
|
|
193
|
+
# sequence an input method produces. `updates` are the visible
|
|
194
|
+
# composition states (e.g. ["に", "にほんご"] while converting to
|
|
195
|
+
# 日本語); each dispatches a `Process` keydown (keyCode 229),
|
|
196
|
+
# compositionupdate, the insertCompositionText beforeinput/input pair
|
|
197
|
+
# (`isComposing: true`, the field's value shows the composing text),
|
|
198
|
+
# and keyup. `commit: true` (default) then fires compositionend with
|
|
199
|
+
# `text` and leaves it in the field; `commit: false` cancels the
|
|
200
|
+
# composition, restoring the field's prior value.
|
|
201
|
+
#
|
|
202
|
+
# browser.ime_input "#q", "日本語", updates: ["に", "にほんご"]
|
|
203
|
+
# browser.ime_input "#q", "", updates: ["に"], commit: false
|
|
204
|
+
#
|
|
205
|
+
# A block runs after each composition state (with the update string),
|
|
206
|
+
# so a test can let virtual time pass mid-composition — e.g. prove a
|
|
207
|
+
# debounced handler that guards on `event.isComposing` stays quiet
|
|
208
|
+
# while the user pauses to pick a conversion candidate:
|
|
209
|
+
#
|
|
210
|
+
# browser.ime_input "#q", "日本語", updates: ["に", "にほんご"] do
|
|
211
|
+
# browser.advance_time(400) # longer than the debounce
|
|
212
|
+
# end
|
|
213
|
+
#
|
|
214
|
+
# Handlers that guard on `event.isComposing` (deferring work until
|
|
215
|
+
# compositionend) therefore behave exactly as they would under a real
|
|
216
|
+
# IME — something a real-browser test cannot drive deterministically.
|
|
217
|
+
def ime_input(selector, text, updates: nil, commit: true)
|
|
218
|
+
element = find(selector)
|
|
219
|
+
EventSynthesis.focus(element)
|
|
220
|
+
text = text.to_s
|
|
221
|
+
updates = Array(updates || (text.empty? ? [] : [text])).map(&:to_s)
|
|
222
|
+
base = element.respond_to?(:value) ? element.value.to_s : ""
|
|
223
|
+
|
|
224
|
+
EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229})
|
|
225
|
+
EventSynthesis.compositionstart(element)
|
|
226
|
+
updates.each_with_index do |update, i|
|
|
227
|
+
EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229, "isComposing" => true}) if i.positive?
|
|
228
|
+
EventSynthesis.compositionupdate(element, update)
|
|
229
|
+
field_interactor.set_composition_text(element, base, update)
|
|
230
|
+
EventSynthesis.keyup(element, "Process", "", {"isComposing" => true})
|
|
231
|
+
yield update if block_given?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
if commit
|
|
235
|
+
unless text == updates.last
|
|
236
|
+
EventSynthesis.compositionupdate(element, text)
|
|
237
|
+
field_interactor.set_composition_text(element, base, text)
|
|
238
|
+
end
|
|
239
|
+
EventSynthesis.compositionend(element, text)
|
|
240
|
+
else
|
|
241
|
+
field_interactor.cancel_composition_text(element, base)
|
|
242
|
+
EventSynthesis.compositionend(element, "")
|
|
243
|
+
end
|
|
244
|
+
after_interaction
|
|
245
|
+
element
|
|
246
|
+
end
|
|
247
|
+
|
|
168
248
|
# --- Matchers ---
|
|
169
249
|
|
|
170
250
|
# True when an element matches `selector` in scope. `text:` keeps only
|
|
@@ -198,16 +278,77 @@ module Dommy
|
|
|
198
278
|
|
|
199
279
|
private
|
|
200
280
|
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
def
|
|
205
|
-
|
|
281
|
+
# The includer (Browser / Rack::Session) may define the actual rule for
|
|
282
|
+
# "is this a submit button"; treat none as present otherwise. Used to pick
|
|
283
|
+
# the default submit button for Enter's implicit submission.
|
|
284
|
+
def submit_button_element?(element)
|
|
285
|
+
respond_to?(:submit_button?, true) && submit_button?(element)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The form's first submit button in tree order (the one Enter's
|
|
289
|
+
# implicit-submission default action would activate), or nil for a
|
|
290
|
+
# buttonless form.
|
|
291
|
+
def default_submit_button(form)
|
|
292
|
+
form.query_selector_all("button, input").find { |el| submit_button_element?(el) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# --- send_keys internals ---
|
|
296
|
+
|
|
297
|
+
def dispatch_send_key(element, key)
|
|
298
|
+
case key
|
|
299
|
+
when Symbol
|
|
300
|
+
named = EventSynthesis::NAMED_KEYS[key] ||
|
|
301
|
+
raise(ArgumentError, "unknown key #{key.inspect} (known: #{EventSynthesis::NAMED_KEYS.keys.join(", ")})")
|
|
302
|
+
send_named_key(element, key, named[0], named[1])
|
|
303
|
+
when String
|
|
304
|
+
key.each_char { |char| send_character(element, char) }
|
|
305
|
+
else
|
|
306
|
+
raise ArgumentError, "send_keys takes Symbols (named keys) or Strings (typed text), got #{key.inspect}"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def send_named_key(element, name, key, code)
|
|
311
|
+
unless EventSynthesis.keydown(element, key, code)
|
|
312
|
+
case name
|
|
313
|
+
when :enter then enter_default_action(element)
|
|
314
|
+
when :space then typed_character_default_action(element, " ", "Space")
|
|
315
|
+
when :backspace then field_interactor.backspace(element)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
EventSynthesis.keyup(element, key, code)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def send_character(element, char)
|
|
322
|
+
code = EventSynthesis.char_code(char)
|
|
323
|
+
typed_character_default_action(element, char, code) unless EventSynthesis.keydown(element, char, code)
|
|
324
|
+
EventSynthesis.keyup(element, char, code)
|
|
325
|
+
end
|
|
206
326
|
|
|
207
|
-
|
|
208
|
-
|
|
327
|
+
# An un-prevented printable keydown fires keypress; an un-prevented
|
|
328
|
+
# keypress inserts the character (beforeinput -> value -> input).
|
|
329
|
+
def typed_character_default_action(element, char, code)
|
|
330
|
+
return if EventSynthesis.keypress(element, char, code)
|
|
209
331
|
|
|
210
|
-
|
|
332
|
+
field_interactor.insert_text(element, char)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Enter's default action: newline in a textarea; elsewhere the owning
|
|
336
|
+
# form's implicit submission — click the form's default (first) submit
|
|
337
|
+
# button so its handlers run, or dispatch a cancelable submit event
|
|
338
|
+
# directly when the form has no submit button (HTML implicit submission).
|
|
339
|
+
def enter_default_action(element)
|
|
340
|
+
return field_interactor.insert_text(element, "\n") if element.local_name == "textarea"
|
|
341
|
+
return unless element.respond_to?(:form) && (form = element.form)
|
|
342
|
+
|
|
343
|
+
submitter = default_submit_button(form)
|
|
344
|
+
if submitter
|
|
345
|
+
# Clicking the default submit button runs its activation behavior
|
|
346
|
+
# (form submission); a prevented click naturally submits nothing.
|
|
347
|
+
EventSynthesis.click(submitter)
|
|
348
|
+
else
|
|
349
|
+
# No submit button: HTML implicit submission with no submitter.
|
|
350
|
+
form.__run_form_submission__(nil)
|
|
351
|
+
end
|
|
211
352
|
end
|
|
212
353
|
|
|
213
354
|
# "no element with role …" plus the roles that WERE present (the most
|
|
@@ -23,21 +23,31 @@ module Dommy
|
|
|
23
23
|
dispatch(element, Dommy::MouseEvent.new("mouseup", mouse_init))
|
|
24
24
|
event = Dommy::MouseEvent.new("click", mouse_init)
|
|
25
25
|
element.dispatch_event(event)
|
|
26
|
-
event.default_prevented?
|
|
26
|
+
prevented = event.default_prevented?
|
|
27
|
+
# Run the click's activation behavior (hyperlink navigation, …) the same
|
|
28
|
+
# way Element#click does, so a synthetic/user click triggers the default
|
|
29
|
+
# action too — unless it was prevented. Checkbox/radio toggling is handled
|
|
30
|
+
# by the field interactor, not here (their activation_target is nil).
|
|
31
|
+
element.__run_click_activation_behavior__(event) if !prevented && element.respond_to?(:__run_click_activation_behavior__)
|
|
32
|
+
prevented
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
# Run the element's focusing steps (Element#focus): moves
|
|
36
|
+
# document.activeElement and fires blur/focusout on the previously
|
|
37
|
+
# focused element plus focus/focusin here — a no-op when the element
|
|
38
|
+
# already holds focus, like a real browser.
|
|
29
39
|
def focus(element)
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
element.focus if element.respond_to?(:focus)
|
|
41
|
+
nil
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
def blur(element)
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
element.blur if element.respond_to?(:blur)
|
|
46
|
+
nil
|
|
37
47
|
end
|
|
38
48
|
|
|
39
|
-
def input(element, data = nil)
|
|
40
|
-
dispatch(element, Dommy::InputEvent.new("input", BUBBLES.merge("data" => data, "inputType" =>
|
|
49
|
+
def input(element, data = nil, input_type = "insertText")
|
|
50
|
+
dispatch(element, Dommy::InputEvent.new("input", BUBBLES.merge("data" => data, "inputType" => input_type)))
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
def change(element)
|
|
@@ -48,6 +58,104 @@ module Dommy
|
|
|
48
58
|
element.dispatch_event(event)
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Named keys for Driver#send_keys, mapped to their KeyboardEvent
|
|
62
|
+
# key/code pairs. Aliases (:up for :arrow_up, …) match Capybara's.
|
|
63
|
+
NAMED_KEYS = {
|
|
64
|
+
enter: ["Enter", "Enter"],
|
|
65
|
+
tab: ["Tab", "Tab"],
|
|
66
|
+
escape: ["Escape", "Escape"],
|
|
67
|
+
space: [" ", "Space"],
|
|
68
|
+
backspace: ["Backspace", "Backspace"],
|
|
69
|
+
delete: ["Delete", "Delete"],
|
|
70
|
+
arrow_up: ["ArrowUp", "ArrowUp"],
|
|
71
|
+
arrow_down: ["ArrowDown", "ArrowDown"],
|
|
72
|
+
arrow_left: ["ArrowLeft", "ArrowLeft"],
|
|
73
|
+
arrow_right: ["ArrowRight", "ArrowRight"],
|
|
74
|
+
up: ["ArrowUp", "ArrowUp"],
|
|
75
|
+
down: ["ArrowDown", "ArrowDown"],
|
|
76
|
+
left: ["ArrowLeft", "ArrowLeft"],
|
|
77
|
+
right: ["ArrowRight", "ArrowRight"],
|
|
78
|
+
home: ["Home", "Home"],
|
|
79
|
+
end: ["End", "End"],
|
|
80
|
+
page_up: ["PageUp", "PageUp"],
|
|
81
|
+
page_down: ["PageDown", "PageDown"],
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
# keydown is cancelable: a prevented keydown suppresses the key's
|
|
85
|
+
# default action (typing, implicit submission). Returns whether it was
|
|
86
|
+
# prevented, like #click.
|
|
87
|
+
def keydown(element, key, code, extra = nil)
|
|
88
|
+
event = Dommy::KeyboardEvent.new("keydown", key_init(key, code, extra))
|
|
89
|
+
element.dispatch_event(event)
|
|
90
|
+
event.default_prevented?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# keypress fires only for keys that produce a character (legacy but
|
|
94
|
+
# still widely handled). Also cancelable; a prevented keypress
|
|
95
|
+
# suppresses the character insertion.
|
|
96
|
+
def keypress(element, key, code)
|
|
97
|
+
event = Dommy::KeyboardEvent.new("keypress", key_init(key, code))
|
|
98
|
+
element.dispatch_event(event)
|
|
99
|
+
event.default_prevented?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def keyup(element, key, code, extra = nil)
|
|
103
|
+
dispatch(element, Dommy::KeyboardEvent.new("keyup", key_init(key, code, extra)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# IME composition events. compositionstart is cancelable per spec;
|
|
107
|
+
# update / end are not.
|
|
108
|
+
def compositionstart(element, data = "")
|
|
109
|
+
dispatch(element, Dommy::CompositionEvent.new("compositionstart", BUBBLES.merge("data" => data)))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compositionupdate(element, data)
|
|
113
|
+
dispatch(element, Dommy::CompositionEvent.new("compositionupdate",
|
|
114
|
+
"bubbles" => true, "composed" => true, "data" => data))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def compositionend(element, data)
|
|
118
|
+
dispatch(element, Dommy::CompositionEvent.new("compositionend",
|
|
119
|
+
"bubbles" => true, "composed" => true, "data" => data))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The input pair during composition: beforeinput then input, both with
|
|
123
|
+
# inputType insertCompositionText and isComposing true. Unlike ordinary
|
|
124
|
+
# typing, the composition beforeinput is NOT cancelable (spec).
|
|
125
|
+
def composition_input(element, data)
|
|
126
|
+
init = {"bubbles" => true, "composed" => true,
|
|
127
|
+
"data" => data, "inputType" => "insertCompositionText", "isComposing" => true}
|
|
128
|
+
dispatch(element, Dommy::InputEvent.new("beforeinput", init))
|
|
129
|
+
dispatch(element, Dommy::InputEvent.new("input", init))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# beforeinput precedes the value mutation and is cancelable (the input
|
|
133
|
+
# event that follows the mutation is not).
|
|
134
|
+
def beforeinput(element, data, input_type)
|
|
135
|
+
event = Dommy::InputEvent.new(
|
|
136
|
+
"beforeinput", BUBBLES.merge("data" => data, "inputType" => input_type)
|
|
137
|
+
)
|
|
138
|
+
element.dispatch_event(event)
|
|
139
|
+
event.default_prevented?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The KeyboardEvent code for a typed character ("a" -> "KeyA").
|
|
143
|
+
# Best-effort: unknown characters get an empty code, like a real
|
|
144
|
+
# browser does for keys it can't map to a physical position.
|
|
145
|
+
def char_code(char)
|
|
146
|
+
case char
|
|
147
|
+
when /\A[a-zA-Z]\z/ then "Key#{char.upcase}"
|
|
148
|
+
when /\A[0-9]\z/ then "Digit#{char}"
|
|
149
|
+
when " " then "Space"
|
|
150
|
+
else ""
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def key_init(key, code, extra = nil)
|
|
155
|
+
init = BUBBLES.merge("key" => key, "code" => code)
|
|
156
|
+
extra ? init.merge(extra) : init
|
|
157
|
+
end
|
|
158
|
+
|
|
51
159
|
def mouse_init
|
|
52
160
|
BUBBLES.merge("button" => 0, "clientX" => 0, "clientY" => 0)
|
|
53
161
|
end
|
|
@@ -83,6 +83,66 @@ module Dommy
|
|
|
83
83
|
select_el
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
TEXT_ENTRY_INPUT_TYPES = %w[text search email url tel password number].freeze
|
|
87
|
+
|
|
88
|
+
def text_entry_field?(element)
|
|
89
|
+
return true if element.local_name == "textarea"
|
|
90
|
+
return false unless element.local_name == "input"
|
|
91
|
+
|
|
92
|
+
type = (element.get_attribute("type") || "text").downcase
|
|
93
|
+
TEXT_ENTRY_INPUT_TYPES.include?(type)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Append `text` to a text-entry field's value (no caret/selection model:
|
|
97
|
+
# insertion is at the end), firing cancelable beforeinput then input.
|
|
98
|
+
# Returns whether the field accepted the insertion (false for a
|
|
99
|
+
# non-text-entry field or a prevented beforeinput) — used by Driver's
|
|
100
|
+
# send_keys to decide whether a key's default action ran.
|
|
101
|
+
def insert_text(element, text)
|
|
102
|
+
return false unless text_entry_field?(element)
|
|
103
|
+
return false if EventSynthesis.beforeinput(element, text, "insertText")
|
|
104
|
+
|
|
105
|
+
element.value = element.value.to_s + text
|
|
106
|
+
EventSynthesis.input(element, text)
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Delete the last character of a text-entry field's value. Same return
|
|
111
|
+
# contract as #insert_text.
|
|
112
|
+
def backspace(element)
|
|
113
|
+
return false unless text_entry_field?(element)
|
|
114
|
+
|
|
115
|
+
value = element.value.to_s
|
|
116
|
+
return false if value.empty?
|
|
117
|
+
return false if EventSynthesis.beforeinput(element, nil, "deleteContentBackward")
|
|
118
|
+
|
|
119
|
+
element.value = value[0...-1]
|
|
120
|
+
EventSynthesis.input(element, nil, "deleteContentBackward")
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Show composition state `update` in a text-entry field (value becomes
|
|
125
|
+
# base + update) with the insertCompositionText beforeinput/input pair.
|
|
126
|
+
# No beforeinput veto: composition inputs are not cancelable (spec).
|
|
127
|
+
# Same return contract as #insert_text.
|
|
128
|
+
def set_composition_text(element, base, update)
|
|
129
|
+
return false unless text_entry_field?(element)
|
|
130
|
+
|
|
131
|
+
element.value = base + update
|
|
132
|
+
EventSynthesis.composition_input(element, update)
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Cancel a composition: restore the field's pre-composition value and
|
|
137
|
+
# fire a deleteCompositionText input. Same return contract.
|
|
138
|
+
def cancel_composition_text(element, base)
|
|
139
|
+
return false unless text_entry_field?(element)
|
|
140
|
+
|
|
141
|
+
element.value = base
|
|
142
|
+
EventSynthesis.input(element, nil, "deleteCompositionText")
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
|
|
86
146
|
private
|
|
87
147
|
|
|
88
148
|
def toggle(box, checked)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Shared ChildNode surface (WHATWG DOM `before` / `after` / `replaceWith`)
|
|
6
|
+
# plus the argument-coercion + childList-notification primitives those and
|
|
7
|
+
# ParentNode's own mutators build on. Included by both ParentNode (Element /
|
|
8
|
+
# Fragment / ShadowRoot) and the leaf CharacterData nodes (Text / Comment /
|
|
9
|
+
# ProcessingInstruction) — a leaf can be moved with `before`/`after` but must
|
|
10
|
+
# NOT gain `appendChild`/`insertBefore`, so those stay in ParentNode.
|
|
11
|
+
#
|
|
12
|
+
# Includers must expose `@__node__` (the backing node) and `@document`.
|
|
13
|
+
module ChildNode
|
|
14
|
+
# ChildNode#before — insert nodes as preceding siblings of `@__node__`.
|
|
15
|
+
# Follows the spec's "viable previous sibling" dance: the reference child
|
|
16
|
+
# is the first preceding sibling NOT among the argument nodes, resolved
|
|
17
|
+
# AFTER the arguments are detached (converting them into a node removes
|
|
18
|
+
# them from their old parents). Nodes are then inserted forward before the
|
|
19
|
+
# (fixed) reference — reversing would emit them backwards.
|
|
20
|
+
def child_node_before(args)
|
|
21
|
+
parent = @__node__.parent
|
|
22
|
+
return nil unless parent
|
|
23
|
+
|
|
24
|
+
arg_nodes = backend_nodes_in(args)
|
|
25
|
+
viable_prev = @__node__.previous_sibling
|
|
26
|
+
viable_prev = viable_prev.previous_sibling while viable_prev && arg_nodes.any? { |n| n == viable_prev }
|
|
27
|
+
|
|
28
|
+
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
29
|
+
ref = viable_prev.nil? ? parent.children.first : viable_prev.next_sibling
|
|
30
|
+
insert_child_nodes(nodes, ref, parent)
|
|
31
|
+
notify_child_list(added: nodes, target: parent)
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ChildNode#after — insert nodes as following siblings of `@__node__`.
|
|
36
|
+
def child_node_after(args)
|
|
37
|
+
parent = @__node__.parent
|
|
38
|
+
return nil unless parent
|
|
39
|
+
|
|
40
|
+
arg_nodes = backend_nodes_in(args)
|
|
41
|
+
viable_next = @__node__.next_sibling
|
|
42
|
+
viable_next = viable_next.next_sibling while viable_next && arg_nodes.any? { |n| n == viable_next }
|
|
43
|
+
|
|
44
|
+
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
45
|
+
insert_child_nodes(nodes, viable_next, parent)
|
|
46
|
+
notify_child_list(added: nodes, target: parent)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ChildNode#replaceWith — replace `@__node__` with the given nodes.
|
|
51
|
+
def child_node_replace_with(args)
|
|
52
|
+
parent = @__node__.parent
|
|
53
|
+
return nil unless parent
|
|
54
|
+
|
|
55
|
+
arg_nodes = backend_nodes_in(args)
|
|
56
|
+
viable_next = @__node__.next_sibling
|
|
57
|
+
viable_next = viable_next.next_sibling while viable_next && arg_nodes.any? { |n| n == viable_next }
|
|
58
|
+
|
|
59
|
+
removed = @__node__
|
|
60
|
+
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
61
|
+
if @__node__.parent == parent
|
|
62
|
+
# `@__node__` survived the conversion (it wasn't among the arguments):
|
|
63
|
+
# insert the new nodes before it, then unlink it — a true replace.
|
|
64
|
+
nodes.each { |n| @__node__.add_previous_sibling(n) }
|
|
65
|
+
@__node__.unlink
|
|
66
|
+
notify_child_list(added: nodes, removed: [removed], target: parent)
|
|
67
|
+
else
|
|
68
|
+
# `@__node__` was itself an argument, so the conversion already moved
|
|
69
|
+
# it into `nodes`; pre-insert the set before the viable next sibling.
|
|
70
|
+
insert_child_nodes(nodes, viable_next, parent)
|
|
71
|
+
notify_child_list(added: nodes, target: parent)
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# WebIDL nullable DOMString coercion (`DOMString?`): JS null and undefined
|
|
77
|
+
# both become the null value, which callers treat as the empty string.
|
|
78
|
+
def nullable_dom_string(value)
|
|
79
|
+
return "" if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
|
|
80
|
+
|
|
81
|
+
value.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# WebIDL check for `insertBefore(node, child)`: `child` is a required but
|
|
85
|
+
# nullable Node, so a missing argument or a value that is neither a Node
|
|
86
|
+
# nor null/undefined is a TypeError before any DOM step runs. Now safe
|
|
87
|
+
# since the seeded stubs report the correct arity (2), so `.length`-based
|
|
88
|
+
# WPT helpers always pass the reference argument.
|
|
89
|
+
def validate_insert_before_ref!(args)
|
|
90
|
+
raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
|
|
91
|
+
|
|
92
|
+
ref = args[1]
|
|
93
|
+
return if ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED))
|
|
94
|
+
return if ref.is_a?(Dommy::Node)
|
|
95
|
+
|
|
96
|
+
raise Bridge::TypeError, "The reference child is not a Node."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Insert `nodes` (raw backend nodes) into `parent` before `ref`, or append
|
|
102
|
+
# when `ref` is nil. Forward iteration against a fixed anchor preserves
|
|
103
|
+
# document order.
|
|
104
|
+
def insert_child_nodes(nodes, ref, parent)
|
|
105
|
+
if ref
|
|
106
|
+
nodes.each { |n| ref.add_previous_sibling(n) }
|
|
107
|
+
else
|
|
108
|
+
nodes.each { |n| parent.add_child(n) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The backing nodes of any ChildNode arguments that are already Nodes
|
|
113
|
+
# (strings / other values have none). Used to skip argument nodes when
|
|
114
|
+
# locating the viable previous / next sibling.
|
|
115
|
+
def backend_nodes_in(args)
|
|
116
|
+
args.filter_map do |arg|
|
|
117
|
+
arg.__dommy_backend_node__ if arg.respond_to?(:__dommy_backend_node__)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Centralized MutationObserver childList notification. Defaults the
|
|
122
|
+
# target to this node; beforebegin/afterend/replaceWith/outerHTML
|
|
123
|
+
# callers pass the parent explicitly. The coordinator filters out
|
|
124
|
+
# empty added/removed sets, so this is always safe to call.
|
|
125
|
+
def notify_child_list(added: [], removed: [], target: @__node__)
|
|
126
|
+
@document.notify_child_list_mutation(
|
|
127
|
+
target_node: target,
|
|
128
|
+
added_nodes: added,
|
|
129
|
+
removed_nodes: removed
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Coerce an append/prepend/replaceChildren/before/after argument into raw
|
|
134
|
+
# backend node(s), detached from any current parent:
|
|
135
|
+
# - Element / TextNode / CommentNode → its backing node (unlinked)
|
|
136
|
+
# - Fragment → its extracted children
|
|
137
|
+
# - String → a fresh text node
|
|
138
|
+
# - anything else with a backing node → that node (unlinked)
|
|
139
|
+
#
|
|
140
|
+
# The class constants resolve at call time, so the mixin only needs to
|
|
141
|
+
# be defined before the including class bodies run.
|
|
142
|
+
def detach_dom_nodes(value)
|
|
143
|
+
case value
|
|
144
|
+
when Fragment
|
|
145
|
+
value.extract_children.map { |n| adopt_into_document(n) }
|
|
146
|
+
when String
|
|
147
|
+
[@document.create_text_node(value).__dommy_backend_node__]
|
|
148
|
+
else
|
|
149
|
+
node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
|
|
150
|
+
return [] unless node
|
|
151
|
+
|
|
152
|
+
# WHATWG pre-insert adopts the node into this node's document before
|
|
153
|
+
# linking it. libxml2 reassigns ownership in place during add_child, so
|
|
154
|
+
# the explicit adopt is a no-op move there; Makiri can't move a node
|
|
155
|
+
# between document arenas, so a cross-document insert must adopt (an
|
|
156
|
+
# imported copy) first. adopt_node reseats the Dommy wrapper onto the
|
|
157
|
+
# adopted node, so JS identity (`parent.appendChild(x); x` ===
|
|
158
|
+
# `parent.lastChild`) survives. Same-document: the wrapper's backend
|
|
159
|
+
# node is unchanged, so this is identical to the previous behavior.
|
|
160
|
+
detach_with_notify(node)
|
|
161
|
+
[@document.adopt_node(value).__dommy_backend_node__]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Bring a raw backend node into this node's document (WHATWG adopt). A
|
|
166
|
+
# no-op when already same-document; otherwise Backend.adopt — in place for
|
|
167
|
+
# Nokogiri, an imported copy for Makiri (which can't move nodes between
|
|
168
|
+
# arenas). Used for fragment children, which have no standalone wrapper to
|
|
169
|
+
# reseat.
|
|
170
|
+
def adopt_into_document(node)
|
|
171
|
+
target = @document.backend_doc
|
|
172
|
+
node.document == target ? node : Backend.adopt(node, target)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Detach a node from its current parent, queuing a childList removal
|
|
176
|
+
# record on that old parent first (WHATWG "remove" runs before the
|
|
177
|
+
# subsequent insert, so moving a node yields a removal record + an addition
|
|
178
|
+
# record). Returns the raw node, ready to be re-linked.
|
|
179
|
+
def detach_with_notify(node)
|
|
180
|
+
old_parent = node.parent
|
|
181
|
+
return node unless old_parent
|
|
182
|
+
|
|
183
|
+
# Capture the position (as wrapped nodes — the coordinator records
|
|
184
|
+
# explicit siblings verbatim) before unlinking.
|
|
185
|
+
prev_sib = node.previous_sibling && @document.wrap_node(node.previous_sibling)
|
|
186
|
+
next_sib = node.next_sibling && @document.wrap_node(node.next_sibling)
|
|
187
|
+
node.unlink
|
|
188
|
+
@document.notify_child_list_mutation(
|
|
189
|
+
target_node: old_parent,
|
|
190
|
+
added_nodes: [],
|
|
191
|
+
removed_nodes: [node],
|
|
192
|
+
previous_sibling: prev_sib,
|
|
193
|
+
next_sibling: next_sib
|
|
194
|
+
)
|
|
195
|
+
node
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -36,9 +36,9 @@ module Dommy
|
|
|
36
36
|
def computed_style(element, pseudo_element: nil)
|
|
37
37
|
document = element.owner_document
|
|
38
38
|
return {}.freeze unless document
|
|
39
|
-
# CSSOM: an element
|
|
39
|
+
# CSSOM: an element that is not rendered has no computed style —
|
|
40
40
|
# browsers return empty strings for every property.
|
|
41
|
-
return {}.freeze if
|
|
41
|
+
return {}.freeze if not_rendered?(element)
|
|
42
42
|
|
|
43
43
|
cache = style_cache(document)
|
|
44
44
|
if pseudo_element
|
|
@@ -50,6 +50,48 @@ module Dommy
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# An element is "not rendered" (and thus has an empty computed style, per
|
|
54
|
+
# CSSOM getComputedStyle) when it is disconnected, outside the flat tree
|
|
55
|
+
# (an unslotted light child of a shadow host), or inside a non-rendered
|
|
56
|
+
# frame (a `display:none` / disconnected `<iframe>`).
|
|
57
|
+
def not_rendered?(element)
|
|
58
|
+
return true if element.respond_to?(:is_connected?) && !element.is_connected?
|
|
59
|
+
return true if outside_flat_tree?(element)
|
|
60
|
+
|
|
61
|
+
in_non_rendered_frame?(element)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Walk flat-tree parents: a light child of a shadow host is in the flat
|
|
65
|
+
# tree only if assigned to a slot, so an unslotted one (and its subtree)
|
|
66
|
+
# is outside it.
|
|
67
|
+
def outside_flat_tree?(element)
|
|
68
|
+
node = element
|
|
69
|
+
while node.respond_to?(:parent_element) && (host = node.parent_element)
|
|
70
|
+
if host.respond_to?(:shadow_root) && host.shadow_root &&
|
|
71
|
+
node.respond_to?(:assigned_slot) && node.assigned_slot.nil?
|
|
72
|
+
return true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
node = host
|
|
76
|
+
end
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Follow the frame chain up to the top document; the element is not
|
|
81
|
+
# rendered if any hosting frame is disconnected or `display:none`.
|
|
82
|
+
def in_non_rendered_frame?(element)
|
|
83
|
+
doc = element.owner_document
|
|
84
|
+
seen = 0
|
|
85
|
+
while doc && (view = (doc.default_view if doc.respond_to?(:default_view))) &&
|
|
86
|
+
(frame = view.frame_element) && (seen += 1) < 64
|
|
87
|
+
return true if frame.respond_to?(:is_connected?) && !frame.is_connected?
|
|
88
|
+
return true if computed_style(frame)["display"] == "none"
|
|
89
|
+
|
|
90
|
+
doc = frame.owner_document
|
|
91
|
+
end
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
53
95
|
# Cheap per-generation gate used by visibility checks: does the
|
|
54
96
|
# document carry any author CSS at all? When it doesn't (and for
|
|
55
97
|
# makiri-less installs), the HTML-level fast path is already exact
|