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