dommy 0.9.0 → 0.11.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 +50 -13
- data/lib/dommy/backend/makiri_adapter.rb +56 -0
- data/lib/dommy/backend.rb +47 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +246 -36
- data/lib/dommy/callable_invoker.rb +16 -4
- data/lib/dommy/css.rb +33 -10
- data/lib/dommy/custom_elements.rb +42 -2
- data/lib/dommy/document.rb +1299 -152
- data/lib/dommy/dom_parser.rb +12 -1
- data/lib/dommy/element.rb +824 -413
- data/lib/dommy/event.rb +744 -115
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +148 -21
- data/lib/dommy/html_elements.rb +2307 -317
- data/lib/dommy/interaction/driver.rb +177 -14
- data/lib/dommy/interaction/event_synthesis.rb +111 -6
- data/lib/dommy/interaction/field_interactor.rb +73 -7
- data/lib/dommy/internal/accessible_name.rb +45 -8
- data/lib/dommy/internal/aria_role.rb +5 -3
- data/lib/dommy/internal/child_node.rb +313 -0
- data/lib/dommy/internal/css/cascade.rb +47 -3
- data/lib/dommy/internal/css/parser.rb +97 -14
- data/lib/dommy/internal/css/rule_index.rb +288 -18
- data/lib/dommy/internal/css_priority.rb +33 -0
- data/lib/dommy/internal/dom_matching.rb +1 -1
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/leaf_node.rb +51 -0
- data/lib/dommy/internal/mutation_coordinator.rb +161 -34
- data/lib/dommy/internal/node_adopter.rb +190 -0
- data/lib/dommy/internal/node_traversal.rb +10 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +109 -26
- data/lib/dommy/internal/observer_manager.rb +24 -0
- data/lib/dommy/internal/observer_matcher.rb +39 -8
- data/lib/dommy/internal/parent_node.rb +345 -82
- data/lib/dommy/internal/range_text_serializer.rb +5 -4
- data/lib/dommy/internal/selector_index.rb +24 -8
- data/lib/dommy/internal/selector_matcher.rb +221 -16
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/internal/template_content_registry.rb +23 -7
- data/lib/dommy/internal/utf16.rb +45 -0
- data/lib/dommy/internal/xml_serialization.rb +18 -5
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +91 -13
- data/lib/dommy/js/host_bridge.rb +86 -15
- data/lib/dommy/js/host_runtime.js +1444 -98
- data/lib/dommy/js/marshaller.rb +12 -7
- data/lib/dommy/js/script_boot.rb +81 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +109 -20
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +207 -27
- data/lib/dommy/parser.rb +14 -0
- data/lib/dommy/promise.rb +4 -4
- data/lib/dommy/range.rb +373 -65
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +65 -17
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +25 -39
- 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 +200 -11
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +5 -0
- metadata +9 -9
data/lib/dommy/browser.rb
CHANGED
|
@@ -56,47 +56,45 @@ module Dommy
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Start a navigable session by fetching the initial document from
|
|
60
|
+
# `resources`, rather than passing literal HTML. Links / forms / location
|
|
61
|
+
# then perform real cross-document navigation (fetch → replace Window + JS
|
|
62
|
+
# realm), with the browser handle (history, resources, error log) surviving.
|
|
63
|
+
# Dommy::Browser.visit("http://localhost/", resources: my_resources)
|
|
64
|
+
def self.visit(url, resources:, **opts)
|
|
65
|
+
browser = new("<!doctype html><html><head></head><body></body></html>",
|
|
66
|
+
url: "about:blank", resources: resources, navigable: true, **opts)
|
|
67
|
+
browser.visit(url, replace: true)
|
|
68
|
+
browser
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
|
|
60
|
-
wasm_memory_shim: false, backend: nil)
|
|
72
|
+
wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
|
|
61
73
|
@resources = resources
|
|
74
|
+
@same_origin = same_origin
|
|
62
75
|
@strict = strict
|
|
76
|
+
@backend = backend
|
|
77
|
+
@execute_scripts = execute_scripts
|
|
78
|
+
@settle_after_boot = settle
|
|
79
|
+
@wasm_memory_shim = wasm_memory_shim
|
|
80
|
+
@navigable = navigable
|
|
63
81
|
@js_errors = []
|
|
64
82
|
@console = []
|
|
65
83
|
@acknowledged = 0
|
|
66
84
|
@allow_errors = false
|
|
67
85
|
@disposed = false
|
|
86
|
+
@pending_navigation = nil
|
|
87
|
+
@runtime = nil
|
|
68
88
|
|
|
69
89
|
@window = Dommy.parse(html)
|
|
70
90
|
@window.location.__internal_set_url__(url) if url
|
|
91
|
+
install_runtime(@window)
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@runtime.on_log { |log| @console << log }
|
|
78
|
-
@runtime.define_host_object("document", @window.document)
|
|
79
|
-
@runtime.install_window(@window)
|
|
80
|
-
@runtime.install_browser_globals
|
|
81
|
-
# Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
|
|
82
|
-
# WebAssembly.Memory); off by default so real pages don't see the shim.
|
|
83
|
-
@runtime.install_wasm_memory_shim if wasm_memory_shim && @runtime.respond_to?(:install_wasm_memory_shim)
|
|
84
|
-
@window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
|
|
85
|
-
|
|
86
|
-
if execute_scripts
|
|
87
|
-
doc = @window.document
|
|
88
|
-
# Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
|
|
89
|
-
# fetch + run through the same resources adapter, after boot.
|
|
90
|
-
doc.external_script_runner = lambda do |element, src|
|
|
91
|
-
Js::ScriptBoot.run_external_script(@runtime, doc, element, src,
|
|
92
|
-
resources: @resources, on_error: ->(e) { @js_errors << e })
|
|
93
|
-
end
|
|
94
|
-
Js::ScriptBoot.run_document_scripts(
|
|
95
|
-
@runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
|
|
96
|
-
)
|
|
97
|
-
# Leave the page in a ready state: run on-load promises, due-now timers,
|
|
98
|
-
# and rAF (not future timers). `settle: false` observes it mid-flight.
|
|
99
|
-
@runtime.settle if settle
|
|
93
|
+
if navigable
|
|
94
|
+
@fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
|
|
95
|
+
@history = Navigation::JointHistory.new
|
|
96
|
+
@window.navigation_delegate = self
|
|
97
|
+
@history.push(current_url, window: @window, windex: @window.history.__internal_index__)
|
|
100
98
|
end
|
|
101
99
|
check_js_errors!
|
|
102
100
|
end
|
|
@@ -106,6 +104,70 @@ module Dommy
|
|
|
106
104
|
# Current document HTML (serialized).
|
|
107
105
|
def html = @window.document.document_element&.outer_html
|
|
108
106
|
|
|
107
|
+
# The current document's URL (the address bar).
|
|
108
|
+
def current_url = @window.location.href
|
|
109
|
+
|
|
110
|
+
# The joint (tab) history of a navigable browser, or nil for a plain
|
|
111
|
+
# single-document browser.
|
|
112
|
+
attr_reader :history
|
|
113
|
+
|
|
114
|
+
# Programmatically navigate to `url` (a Ruby-initiated visit). Only
|
|
115
|
+
# meaningful for a navigable browser; performs the fetch + document swap
|
|
116
|
+
# immediately (there is no JS on the stack).
|
|
117
|
+
def visit(url, replace: false)
|
|
118
|
+
raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
|
|
119
|
+
|
|
120
|
+
@pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
|
|
121
|
+
flush_navigation!
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Reload the current document (re-fetch, replace the current history entry).
|
|
126
|
+
def reload
|
|
127
|
+
visit(current_url, replace: true)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Move back / forward one entry in the joint history. A same-document target
|
|
131
|
+
# (the entry's window is still live) traverses in place (popstate); a
|
|
132
|
+
# document-boundary target is re-fetched (no bfcache — D2).
|
|
133
|
+
def back = traverse(-1)
|
|
134
|
+
def forward = traverse(1)
|
|
135
|
+
|
|
136
|
+
# --- NavigationDelegate port (see Dommy::Navigation) ---
|
|
137
|
+
|
|
138
|
+
# A cross-document navigation intent (link / form / location). Navigation is
|
|
139
|
+
# a task: rather than swap the Window + JS realm synchronously (which may run
|
|
140
|
+
# while the outgoing realm's JS is still on the stack — e.g. `location.href =`
|
|
141
|
+
# inside a script), record it and perform the fetch + swap at the next drain
|
|
142
|
+
# boundary (settle / after_interaction / advance_time). Ruby-initiated visits
|
|
143
|
+
# flush immediately since no JS is on the stack.
|
|
144
|
+
def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
|
|
145
|
+
@pending_navigation = {
|
|
146
|
+
url: url, method: method, body: body, params: params, enctype: enctype,
|
|
147
|
+
headers: headers, replace: replace, source: source
|
|
148
|
+
}
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A cross-document history traversal. Ruby-initiated (back / forward), so it
|
|
153
|
+
# runs immediately: a same-document target (its window is still live)
|
|
154
|
+
# traverses in place (popstate); a document-boundary target is re-fetched.
|
|
155
|
+
def traverse(delta)
|
|
156
|
+
return self unless @navigable
|
|
157
|
+
|
|
158
|
+
entry = delta.negative? ? @history.back : @history.forward
|
|
159
|
+
return self unless entry
|
|
160
|
+
|
|
161
|
+
if entry.window && entry.window.equal?(@window)
|
|
162
|
+
@window.history.__internal_go_to__(entry.windex)
|
|
163
|
+
@runtime.drain_microtasks
|
|
164
|
+
check_js_errors!
|
|
165
|
+
else
|
|
166
|
+
perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
|
|
167
|
+
end
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
109
171
|
# Evaluate an expression / statement body and return the decoded value.
|
|
110
172
|
def evaluate(js)
|
|
111
173
|
result = @runtime.evaluate(js)
|
|
@@ -125,6 +187,7 @@ module Dommy
|
|
|
125
187
|
# `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
|
|
126
188
|
def settle
|
|
127
189
|
@runtime.settle
|
|
190
|
+
flush_navigation!
|
|
128
191
|
check_js_errors!
|
|
129
192
|
self
|
|
130
193
|
end
|
|
@@ -133,6 +196,7 @@ module Dommy
|
|
|
133
196
|
def advance_time(ms)
|
|
134
197
|
@window.scheduler.advance_time(ms)
|
|
135
198
|
@runtime.drain_microtasks
|
|
199
|
+
flush_navigation!
|
|
136
200
|
check_js_errors!
|
|
137
201
|
self
|
|
138
202
|
end
|
|
@@ -142,25 +206,29 @@ module Dommy
|
|
|
142
206
|
# land before the next line, then enforce strict mode.
|
|
143
207
|
def after_interaction
|
|
144
208
|
@runtime.drain_microtasks
|
|
209
|
+
flush_navigation!
|
|
145
210
|
check_js_errors!
|
|
146
211
|
end
|
|
147
212
|
|
|
148
213
|
# Click a submit-capable button. The button's click event fires (JS may
|
|
149
214
|
# handle / preventDefault it); if it is an un-prevented submit button, the
|
|
150
|
-
# form's
|
|
151
|
-
#
|
|
215
|
+
# owning form's submission algorithm runs (a real SubmitEvent a SPA can
|
|
216
|
+
# intercept, then the delegate navigation). In a navigable browser that
|
|
217
|
+
# follows the submit for real; otherwise the delegate just records it.
|
|
152
218
|
def click_button(locator)
|
|
153
219
|
button = finder.find_button(locator)
|
|
154
|
-
prevented
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
220
|
+
# An un-prevented click runs the button's activation behavior — a submit
|
|
221
|
+
# button submits its owning form (real SubmitEvent + delegate navigation),
|
|
222
|
+
# so a navigable browser follows the submit for real.
|
|
223
|
+
Dommy::Interaction::EventSynthesis.click(button)
|
|
158
224
|
after_interaction
|
|
159
225
|
button
|
|
160
226
|
end
|
|
161
227
|
|
|
162
228
|
# Click a link, firing its click event so SPA JS (Turbo, React Router, …)
|
|
163
|
-
# can intercept.
|
|
229
|
+
# can intercept. An un-prevented click runs the anchor's activation behavior
|
|
230
|
+
# (follow-the-hyperlink); in a navigable browser that navigates for real,
|
|
231
|
+
# otherwise the delegate records it.
|
|
164
232
|
def click_link(locator)
|
|
165
233
|
link = finder.find_link(locator)
|
|
166
234
|
Dommy::Interaction::EventSynthesis.click(link)
|
|
@@ -190,6 +258,148 @@ module Dommy
|
|
|
190
258
|
|
|
191
259
|
private
|
|
192
260
|
|
|
261
|
+
# Build a fresh JS realm for `window`, wire error/console/fetch/external-
|
|
262
|
+
# script seams, and boot its `<script>` tags. Disposes the previous realm
|
|
263
|
+
# first (a no-op on the initial load), so a navigation tears the outgoing
|
|
264
|
+
# realm — and with it every pending timer / microtask on the old Window's
|
|
265
|
+
# scheduler — down before the new page runs.
|
|
266
|
+
def install_runtime(window)
|
|
267
|
+
@runtime&.dispose
|
|
268
|
+
# The JS engine is pluggable: `@backend` selects a registered runtime
|
|
269
|
+
# (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
|
|
270
|
+
runtime = Js.build_runtime(@backend)
|
|
271
|
+
runtime.on_unhandled_rejection { |err| @js_errors << err }
|
|
272
|
+
runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
|
|
273
|
+
runtime.on_log { |log| @console << log }
|
|
274
|
+
runtime.define_host_object("document", window.document)
|
|
275
|
+
runtime.install_window(window)
|
|
276
|
+
runtime.install_browser_globals
|
|
277
|
+
# Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
|
|
278
|
+
# WebAssembly.Memory); off by default so real pages don't see the shim.
|
|
279
|
+
runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
|
|
280
|
+
window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
|
|
281
|
+
@runtime = runtime
|
|
282
|
+
doc = window.document
|
|
283
|
+
# An `on*` attribute that arrived after boot (a cloned template, an
|
|
284
|
+
# innerHTML fragment) is compiled on first dispatch, which replays the scan.
|
|
285
|
+
# Installed whenever a runtime is attached — an embedder that drives script
|
|
286
|
+
# boot itself (`execute_scripts: false`) still needs inline handlers wired.
|
|
287
|
+
doc.inline_handler_wirer = lambda do
|
|
288
|
+
Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
|
|
289
|
+
end
|
|
290
|
+
return unless @execute_scripts
|
|
291
|
+
|
|
292
|
+
# Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
|
|
293
|
+
# fetch + run through the same resources adapter, after boot.
|
|
294
|
+
doc.external_script_runner = lambda do |element, src|
|
|
295
|
+
Js::ScriptBoot.run_external_script(runtime, doc, element, src,
|
|
296
|
+
resources: @resources, on_error: ->(e) { @js_errors << e })
|
|
297
|
+
end
|
|
298
|
+
Js::ScriptBoot.run_document_scripts(
|
|
299
|
+
runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
|
|
300
|
+
)
|
|
301
|
+
# Leave the page in a ready state: run on-load promises, due-now timers,
|
|
302
|
+
# and rAF (not future timers). `settle: false` observes it mid-flight.
|
|
303
|
+
runtime.settle if @settle_after_boot
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Perform a recorded navigation: fetch the target (following redirects),
|
|
307
|
+
# fire the old document's unload, then replace the Window + JS realm with the
|
|
308
|
+
# freshly parsed document and update the joint history. A network miss or a
|
|
309
|
+
# non-document response leaves the current page in place.
|
|
310
|
+
MAX_META_REFRESHES = 20
|
|
311
|
+
|
|
312
|
+
def perform_navigation!(nav, rebind: false, refresh_depth: 0)
|
|
313
|
+
response, final_url = @fetcher.request(
|
|
314
|
+
method: nav[:method] || "GET", url: nav[:url], params: nav[:params],
|
|
315
|
+
body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
|
|
316
|
+
)
|
|
317
|
+
return unless response&.success? && document_response?(response)
|
|
318
|
+
|
|
319
|
+
# Fire the outgoing document's unload sequence while its realm is still
|
|
320
|
+
# alive, then surface any of its errors before the realm is torn down.
|
|
321
|
+
fire_unload(@window)
|
|
322
|
+
check_js_errors!
|
|
323
|
+
|
|
324
|
+
new_window = Dommy.parse(response.body)
|
|
325
|
+
new_window.location.__internal_set_url__(final_url)
|
|
326
|
+
new_window.navigation_delegate = self
|
|
327
|
+
@window = new_window
|
|
328
|
+
install_runtime(new_window)
|
|
329
|
+
|
|
330
|
+
windex = new_window.history.__internal_index__
|
|
331
|
+
if rebind || nav[:replace]
|
|
332
|
+
@history.rebind_current(url: final_url, window: new_window, windex: windex)
|
|
333
|
+
else
|
|
334
|
+
@history.push(final_url, window: new_window, windex: windex)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
follow_meta_refresh!(refresh_depth)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# If the freshly loaded document asks for an immediate `<meta http-equiv=
|
|
341
|
+
# refresh>`, follow it (as a replace, like a redirect), capped so a page that
|
|
342
|
+
# refreshes to itself can't loop forever.
|
|
343
|
+
def follow_meta_refresh!(depth)
|
|
344
|
+
return if depth >= MAX_META_REFRESHES
|
|
345
|
+
|
|
346
|
+
target = meta_refresh_target(@window.document)
|
|
347
|
+
return unless target
|
|
348
|
+
|
|
349
|
+
perform_navigation!({url: target, method: "GET", source: :meta_refresh},
|
|
350
|
+
rebind: true, refresh_depth: depth + 1)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The resolved URL a `<meta http-equiv="refresh" content="0; url=…">` points
|
|
354
|
+
# at, or nil when the document has none (or a refresh with no URL, which just
|
|
355
|
+
# reloads and is left alone to avoid a busy loop).
|
|
356
|
+
def meta_refresh_target(document)
|
|
357
|
+
document.query_selector_all("meta").each do |meta|
|
|
358
|
+
next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
|
|
359
|
+
|
|
360
|
+
_delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
|
|
361
|
+
next if separator.empty?
|
|
362
|
+
|
|
363
|
+
url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip
|
|
364
|
+
return resolve_against_current(url) unless url.empty?
|
|
365
|
+
end
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def resolve_against_current(url)
|
|
370
|
+
URI.join(current_url, url).to_s
|
|
371
|
+
rescue URI::InvalidURIError
|
|
372
|
+
url
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Perform a pending navigation recorded by the delegate (JS-initiated
|
|
376
|
+
# location.href= / form submit / link click). Called at drain boundaries so
|
|
377
|
+
# the swap never runs with the outgoing realm's JS on the stack.
|
|
378
|
+
def flush_navigation!
|
|
379
|
+
return unless @navigable
|
|
380
|
+
|
|
381
|
+
nav = @pending_navigation
|
|
382
|
+
return unless nav
|
|
383
|
+
|
|
384
|
+
@pending_navigation = nil
|
|
385
|
+
perform_navigation!(nav)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def fire_unload(window)
|
|
389
|
+
window.dispatch_event(Dommy::Event.new("pagehide"))
|
|
390
|
+
window.dispatch_event(Dommy::Event.new("unload"))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Only HTML/XML responses replace the document; other content types (a JSON
|
|
394
|
+
# API hit, an image) leave the current page. A response with no Content-Type
|
|
395
|
+
# is treated as a document (fixtures commonly omit it).
|
|
396
|
+
def document_response?(response)
|
|
397
|
+
headers = response.headers || {}
|
|
398
|
+
key = headers.keys.find { |k| k.to_s.casecmp?("content-type") }
|
|
399
|
+
content_type = key ? headers[key].to_s.downcase : ""
|
|
400
|
+
content_type.empty? || content_type.include?("html") || content_type.include?("xml")
|
|
401
|
+
end
|
|
402
|
+
|
|
193
403
|
def unacknowledged = @js_errors[@acknowledged..] || []
|
|
194
404
|
|
|
195
405
|
def submit_button?(button)
|
|
@@ -22,14 +22,26 @@ module Dommy
|
|
|
22
22
|
|
|
23
23
|
# Invoke a DOM event listener per the EventTarget rule: an object with
|
|
24
24
|
# `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
|
|
25
|
-
# that order).
|
|
26
|
-
|
|
25
|
+
# that order). A JS function listener's `this` must be the event's
|
|
26
|
+
# currentTarget (the node the listener is attached to), so pass it through
|
|
27
|
+
# when the bridge supports an explicit receiver.
|
|
28
|
+
# `args:` overrides the single-event argument list — the special error
|
|
29
|
+
# event handler (`window.onerror`) is called with (message, filename,
|
|
30
|
+
# lineno, colno, error) instead of the event. An EventListener object's
|
|
31
|
+
# handleEvent always receives the event.
|
|
32
|
+
def invoke_listener(listener, event, current_target = nil, args: nil)
|
|
33
|
+
args ||= [event]
|
|
27
34
|
if listener.respond_to?(:handle_event)
|
|
28
35
|
listener.handle_event(event)
|
|
29
36
|
elsif listener.respond_to?(:call) && !listener.is_a?(Module)
|
|
30
|
-
listener.call(
|
|
37
|
+
listener.call(*args)
|
|
38
|
+
elsif listener.respond_to?(:__js_invoke__)
|
|
39
|
+
# A JS function listener: `this` is the currentTarget, and a thrown value
|
|
40
|
+
# surfaces (as a ThrowValue) so the dispatch reports it as a window
|
|
41
|
+
# `error` event instead of swallowing it.
|
|
42
|
+
listener.__js_invoke__(args, this: current_target, raising: true)
|
|
31
43
|
elsif listener.respond_to?(:__js_call__)
|
|
32
|
-
listener.__js_call__("call",
|
|
44
|
+
listener.__js_call__("call", args)
|
|
33
45
|
end
|
|
34
46
|
end
|
|
35
47
|
end
|
data/lib/dommy/css.rb
CHANGED
|
@@ -18,7 +18,9 @@ module Dommy
|
|
|
18
18
|
class CSSStyleSheet
|
|
19
19
|
attr_reader :owner_node, :css_rules
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# A stylesheet constructed by `new CSSStyleSheet()` has no owner node.
|
|
22
|
+
# Element-owned sheets still pass their <style> / <link> node explicitly.
|
|
23
|
+
def initialize(owner_node: nil, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
|
|
22
24
|
@owner_node = owner_node
|
|
23
25
|
@href = href
|
|
24
26
|
@media = media
|
|
@@ -291,21 +293,27 @@ module Dommy
|
|
|
291
293
|
end
|
|
292
294
|
|
|
293
295
|
def get_property_value(name)
|
|
294
|
-
entry = @props[name
|
|
296
|
+
entry = @props[property_key(name)]
|
|
295
297
|
entry ? entry[:value] : ""
|
|
296
298
|
end
|
|
297
299
|
|
|
298
300
|
def get_property_priority(name)
|
|
299
|
-
entry = @props[name
|
|
301
|
+
entry = @props[property_key(name)]
|
|
300
302
|
entry ? entry[:priority] : ""
|
|
301
303
|
end
|
|
302
304
|
|
|
303
305
|
def set_property(name, value, priority = nil)
|
|
304
|
-
key = name
|
|
306
|
+
key = property_key(name)
|
|
305
307
|
if value.nil? || value.to_s.empty?
|
|
308
|
+
# CSSOM step 3 — an empty value removes the declaration, and it runs
|
|
309
|
+
# before the priority check, so the priority is irrelevant here.
|
|
306
310
|
@props.delete(key)
|
|
307
311
|
else
|
|
308
|
-
|
|
312
|
+
# CSSOM step 4 — an invalid priority abandons the call, leaving the
|
|
313
|
+
# declaration block exactly as it was.
|
|
314
|
+
important = Internal::CssPriority.normalize(priority)
|
|
315
|
+
return nil if important.nil?
|
|
316
|
+
|
|
309
317
|
@props[key] = {value: value.to_s, priority: important}
|
|
310
318
|
end
|
|
311
319
|
flush!
|
|
@@ -313,7 +321,7 @@ module Dommy
|
|
|
313
321
|
end
|
|
314
322
|
|
|
315
323
|
def remove_property(name)
|
|
316
|
-
removed = @props.delete(name
|
|
324
|
+
removed = @props.delete(property_key(name))
|
|
317
325
|
flush!
|
|
318
326
|
removed ? removed[:value] : ""
|
|
319
327
|
end
|
|
@@ -403,6 +411,13 @@ module Dommy
|
|
|
403
411
|
|
|
404
412
|
private
|
|
405
413
|
|
|
414
|
+
# CSSOM normalizes every property name it is handed (see
|
|
415
|
+
# Internal::CSS::Parser.property_name); `css_name` below is the separate
|
|
416
|
+
# camelCase / snake_case → kebab conversion for the IDL accessors.
|
|
417
|
+
def property_key(name)
|
|
418
|
+
Internal::CSS::Parser.property_name(name)
|
|
419
|
+
end
|
|
420
|
+
|
|
406
421
|
def css_name(name)
|
|
407
422
|
str = name.to_s
|
|
408
423
|
return str if str.start_with?("--")
|
|
@@ -414,8 +429,8 @@ module Dommy
|
|
|
414
429
|
# reusing the cascade's declaration parser (same normalization the cascade
|
|
415
430
|
# sees) so reads agree with computed style.
|
|
416
431
|
def parse(body_text)
|
|
417
|
-
Internal::CSS::Parser.
|
|
418
|
-
|
|
432
|
+
Internal::CSS::Parser.parse_block(body_text).transform_values do |decl|
|
|
433
|
+
{value: decl.value, priority: decl.important ? "important" : ""}
|
|
419
434
|
end
|
|
420
435
|
end
|
|
421
436
|
|
|
@@ -458,8 +473,16 @@ module Dommy
|
|
|
458
473
|
@parent_style_sheet = parent_style_sheet
|
|
459
474
|
end
|
|
460
475
|
|
|
476
|
+
# CSSOM serializes a style rule as its selector plus its declaration block,
|
|
477
|
+
# so the text is rebuilt from the parsed declarations rather than echoed
|
|
478
|
+
# back: `#foo { color: red }` reads as `#foo { color: red; }`, and a block
|
|
479
|
+
# whose contents are not declarations at all serializes empty. An at-rule
|
|
480
|
+
# keeps its text as given.
|
|
461
481
|
def css_text
|
|
462
|
-
@css_text
|
|
482
|
+
return @css_text unless style_rule?
|
|
483
|
+
|
|
484
|
+
declarations = style&.css_text.to_s
|
|
485
|
+
declarations.empty? ? "#{selector_text} { }" : "#{selector_text} { #{declarations} }"
|
|
463
486
|
end
|
|
464
487
|
|
|
465
488
|
def css_text=(v)
|
|
@@ -553,7 +576,7 @@ module Dommy
|
|
|
553
576
|
|
|
554
577
|
def __js_get__(key)
|
|
555
578
|
case key
|
|
556
|
-
when "cssText" then
|
|
579
|
+
when "cssText" then css_text
|
|
557
580
|
when "type" then type
|
|
558
581
|
when "selectorText" then selector_text
|
|
559
582
|
when "style" then style
|
|
@@ -34,6 +34,15 @@ module Dommy
|
|
|
34
34
|
@pending_promises = {}
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Whether `name` is a valid custom element name. Also consulted by
|
|
38
|
+
# `element_class_for`: an unrecognized HTML-namespace name that is valid here
|
|
39
|
+
# is an *undefined custom element* (interface HTMLElement), not an unknown
|
|
40
|
+
# element (HTMLUnknownElement).
|
|
41
|
+
def self.valid_name?(name)
|
|
42
|
+
key = name.to_s
|
|
43
|
+
key.match?(NAME_RE) && !RESERVED_NAMES.include?(key)
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
def define(name, klass, _options = nil)
|
|
38
47
|
key = name.to_s
|
|
39
48
|
unless key.match?(NAME_RE)
|
|
@@ -90,7 +99,13 @@ module Dommy
|
|
|
90
99
|
# Force re-wrap by clearing the document's cached wrapper.
|
|
91
100
|
@window.document.__internal_reset_wrapper__(nk)
|
|
92
101
|
wrapped = @window.document.wrap_node(nk)
|
|
93
|
-
|
|
102
|
+
next unless wrapped
|
|
103
|
+
|
|
104
|
+
replay_observed_attributes(wrapped)
|
|
105
|
+
# connectedCallback is enqueued only for an element that is actually in
|
|
106
|
+
# a document tree — `customElements.upgrade()` on a detached subtree
|
|
107
|
+
# upgrades it without connecting it.
|
|
108
|
+
@window.document.__internal_notify_connected__(wrapped) if wrapped.is_connected?
|
|
94
109
|
end
|
|
95
110
|
|
|
96
111
|
nil
|
|
@@ -135,7 +150,32 @@ module Dommy
|
|
|
135
150
|
|
|
136
151
|
doc.__internal_reset_wrapper__(nk)
|
|
137
152
|
wrapped = doc.wrap_node(nk)
|
|
138
|
-
|
|
153
|
+
next unless wrapped
|
|
154
|
+
|
|
155
|
+
replay_observed_attributes(wrapped)
|
|
156
|
+
doc.__internal_notify_connected__(wrapped)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# "Upgrade an element" step 6: the element's *existing* attributes are
|
|
161
|
+
# replayed through attributeChangedCallback (oldValue null) before
|
|
162
|
+
# connectedCallback, so a definition registered after the markup was parsed
|
|
163
|
+
# still sees the attributes that were already there.
|
|
164
|
+
def replay_observed_attributes(element)
|
|
165
|
+
klass = element.class
|
|
166
|
+
return unless klass.respond_to?(:observed_attributes)
|
|
167
|
+
|
|
168
|
+
observed = Array(klass.observed_attributes).map { |a| a.to_s.downcase }
|
|
169
|
+
return if observed.empty?
|
|
170
|
+
|
|
171
|
+
element.get_attribute_names.each do |name|
|
|
172
|
+
next unless observed.include?(name.to_s.downcase)
|
|
173
|
+
|
|
174
|
+
# Routed through the coordinator so the callback's arity handling (the
|
|
175
|
+
# optional 4th namespace argument) stays in one place.
|
|
176
|
+
@window.document.__internal_notify_attribute_changed__(
|
|
177
|
+
element, name, nil, element.get_attribute(name)
|
|
178
|
+
)
|
|
139
179
|
end
|
|
140
180
|
end
|
|
141
181
|
|