dommy 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
@@ -25,18 +25,77 @@ module Dommy
25
25
  # Shared `getElementsByTagNameNS(namespace, localName)` — a live collection
26
26
  # of descendants of `root` matching the (namespace, localName) filter, where
27
27
  # "*" matches any. An empty-string namespace means the null namespace.
28
+ HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
29
+
30
+ # WHATWG `getElementsByTagName(qualifiedName)` — a live collection filtered
31
+ # by qualified name. "*" matches any. In an HTML document, HTML-namespace
32
+ # elements match case-insensitively (ASCII), while other-namespace elements
33
+ # (and everything in a non-HTML document) match case-sensitively.
34
+ def self.elements_by_tag_name(root, document, qualified_name)
35
+ qn = qualified_name.to_s
36
+ html_doc = document.respond_to?(:html_document?) ? document.html_document? : true
37
+ qn_lower = ascii_downcase(qn)
38
+ new do
39
+ root.css("*").filter_map do |node|
40
+ el = document.wrap_node(node)
41
+ next nil unless el
42
+ next el if qn == "*"
43
+
44
+ el_qn = qualified_name_of(el)
45
+ el_ns = el.respond_to?(:namespace_uri) ? el.namespace_uri : nil
46
+ # For an HTML-namespace element in an HTML document, only the QUERY is
47
+ # ASCII-lowercased — the element's own qualified name is compared as-is
48
+ # (so an uppercase-localName HTML element, e.g. createElementNS(html,
49
+ # "I"), never matches "i" or "I").
50
+ match =
51
+ if html_doc && el_ns == HTML_NAMESPACE
52
+ el_qn == qn_lower
53
+ else
54
+ el_qn == qn
55
+ end
56
+ match ? el : nil
57
+ end
58
+ end
59
+ end
60
+
61
+ # The element's qualified name (prefix:localName, or just localName). The
62
+ # backend node name can't be trusted — the HTML parser lowercases it — so
63
+ # rebuild it from the case-preserving local name and prefix.
64
+ def self.qualified_name_of(el)
65
+ local = el.respond_to?(:local_name) ? el.local_name.to_s : ""
66
+ prefix = el.respond_to?(:__js_get__) ? el.__js_get__("prefix") : nil
67
+ prefix = nil if prefix.nil? || prefix.to_s.empty? ||
68
+ (defined?(Bridge::UNDEFINED) && prefix.equal?(Bridge::UNDEFINED))
69
+ prefix ? "#{prefix}:#{local}" : local
70
+ end
71
+
72
+ # ASCII-only lowercase (A-Z -> a-z), leaving non-ASCII code points intact,
73
+ # per the spec's "converted to ASCII lowercase".
74
+ def self.ascii_downcase(str)
75
+ str.gsub(/[A-Z]/) { |c| (c.ord + 32).chr }
76
+ end
77
+
28
78
  def self.elements_by_tag_name_ns(root, document, namespace, local_name)
29
79
  ns = namespace.to_s
30
80
  ns_filter = ns == "*" ? :any : (ns.empty? ? nil : ns)
31
81
  local = local_name.to_s
82
+ local_filter = local == "*" ? :any : local
32
83
  new do
33
- nodes = local == "*" ? root.css("*") : root.css(local)
34
- nodes.filter_map do |node|
84
+ # Match on the element's LOCAL NAME (case-sensitive, exact) and
85
+ # namespace — NOT a CSS type selector, which is case-insensitive in an
86
+ # HTML document and keys off the qualified name (so it misses a
87
+ # prefixed `test:body` and wrongly matches `BODY` for `body`).
88
+ root.css("*").filter_map do |node|
35
89
  el = document.wrap_node(node)
36
90
  next nil unless el
37
91
 
38
92
  el_ns = el.respond_to?(:namespace_uri) ? el.namespace_uri : nil
39
- (ns_filter == :any || el_ns == ns_filter) ? el : nil
93
+ next nil unless ns_filter == :any || el_ns == ns_filter
94
+
95
+ el_local = el.respond_to?(:local_name) ? el.local_name : nil
96
+ next nil unless local_filter == :any || el_local == local_filter
97
+
98
+ el
40
99
  end
41
100
  end
42
101
  end
@@ -69,7 +128,14 @@ module Dommy
69
128
  to_a.find do |el|
70
129
  next false unless el.respond_to?(:__dommy_backend_node__)
71
130
 
72
- el.__dommy_backend_node__["id"].to_s == key || el.__dommy_backend_node__["name"].to_s == key
131
+ node = el.__dommy_backend_node__
132
+ # `id` matches any element; `name` matches only HTML-namespace elements
133
+ # (WebIDL supported property names), so a null-namespace element's
134
+ # `name` attribute isn't a supported name.
135
+ next true if node["id"].to_s == key
136
+
137
+ html_ns = !el.respond_to?(:namespace_uri) || el.namespace_uri == "http://www.w3.org/1999/xhtml"
138
+ html_ns && node["name"].to_s == key
73
139
  end
74
140
  end
75
141
 
@@ -112,9 +178,11 @@ module Dommy
112
178
  item(key)
113
179
  else
114
180
  s = key.to_s
115
- if s.match?(/\A\d+\z/) && s.to_i < 4_294_967_295
116
- # A valid array index (0 ≤ n < 2^32-1) is a pure indexed lookup — out
117
- # of range yields nil (→ undefined), never a named fallback.
181
+ if s.match?(/\A\d+\z/) && s.to_i < 4_294_967_295 && s == s.to_i.to_s
182
+ # A valid array index is the CANONICAL decimal of 0 ≤ n < 2^32-1 (no
183
+ # leading zeros: "03" is NOT an index, it is a named key). A pure
184
+ # indexed lookup — out of range yields nil (→ undefined), no named
185
+ # fallback.
118
186
  item(s.to_i)
119
187
  else
120
188
  # Non-array-index strings (negative, ≥ 2^32-1, or names) use the named
@@ -157,6 +225,35 @@ module Dommy
157
225
  end
158
226
  end
159
227
 
228
+ # `HTMLFormControlsCollection` — a form's `elements`. Like HTMLCollection but
229
+ # its named getter returns a RadioNodeList when a name/id matches more than one
230
+ # control (e.g. a radio group), and the single control otherwise.
231
+ class HTMLFormControlsCollection < HTMLCollection
232
+ def named_item(name)
233
+ key = name.to_s
234
+ return nil if key.empty?
235
+
236
+ matches = controls_named(key)
237
+ return nil if matches.empty?
238
+ return matches.first if matches.length == 1
239
+
240
+ # A live RadioNodeList: a reference held across a DOM mutation reflects the
241
+ # updated group (per spec the named getter returns a live NodeList).
242
+ coll = self
243
+ RadioNodeList.new(matches) { coll.controls_named(key) }
244
+ end
245
+
246
+ # The controls in this collection whose id or name equals `key`, in order.
247
+ def controls_named(key)
248
+ to_a.select do |el|
249
+ next false unless el.respond_to?(:__dommy_backend_node__)
250
+
251
+ node = el.__dommy_backend_node__
252
+ node["id"].to_s == key || node["name"].to_s == key
253
+ end
254
+ end
255
+ end
256
+
160
257
  # `HTMLOptionsCollection` — specialized `<select>.options` collection.
161
258
  # Adds `add(option, before?)`, `remove(index)`, the `selectedIndex`
162
259
  # getter/setter, and a `length=` setter that truncates or extends.
@@ -169,24 +266,25 @@ module Dommy
169
266
  @owner = owner
170
267
  end
171
268
 
172
- # Append (or insert before `before`) an option element. `before`
173
- # accepts either another option (insert before that node) or an
174
- # integer index. Strings/`null` append.
269
+ # Append (or insert before `before`) an option element. `before` accepts
270
+ # another element (insert before it) or an integer index. Strings/`null`
271
+ # append. The insertion happens in the REFERENCE's parent — which may be an
272
+ # `<optgroup>` — not always the select itself.
175
273
  def add(option, before = nil)
176
274
  return nil unless option.respond_to?(:__dommy_backend_node__)
177
275
 
178
- case before
179
- when nil
180
- @owner.append_child(option)
181
- when Integer
182
- anchor = item(before)
183
- anchor ? @owner.insert_before(option, anchor) : @owner.append_child(option)
184
- else
185
- if before.respond_to?(:__dommy_backend_node__)
186
- @owner.insert_before(option, before)
187
- else
188
- @owner.append_child(option)
276
+ reference =
277
+ case before
278
+ when nil then nil
279
+ when Integer then item(before)
280
+ else before.respond_to?(:__dommy_backend_node__) ? before : nil
189
281
  end
282
+
283
+ parent = reference&.parent_node
284
+ if reference && parent.respond_to?(:insert_before)
285
+ parent.insert_before(option, reference)
286
+ else
287
+ @owner.append_child(option)
190
288
  end
191
289
 
192
290
  nil
@@ -198,6 +296,29 @@ module Dommy
198
296
  nil
199
297
  end
200
298
 
299
+ # WebIDL "set an indexed property" for HTMLOptionsCollection:
300
+ # * a null value removes the option at `index`
301
+ # * otherwise, an in-range index replaces that option; an index at or past
302
+ # the end appends (padding with blank options for any gap).
303
+ def __set_indexed__(index, option)
304
+ i = index.to_i
305
+ if option.nil?
306
+ remove(i)
307
+ return nil
308
+ end
309
+ return nil unless option.respond_to?(:__dommy_backend_node__)
310
+
311
+ current = to_a
312
+ if i < current.length
313
+ @owner.insert_before(option, current[i])
314
+ current[i].remove
315
+ else
316
+ (i - current.length).times { @owner.append_child(@owner.document.create_element("option")) }
317
+ @owner.append_child(option)
318
+ end
319
+ nil
320
+ end
321
+
201
322
  def selected_index
202
323
  @owner.selected_index
203
324
  end
@@ -237,6 +358,9 @@ module Dommy
237
358
  when "length"
238
359
  self.length = value
239
360
  else
361
+ # Indexed property setter: `options[i] = option | null`.
362
+ return __set_indexed__(key.to_i, value) if key.is_a?(Integer) || (key.is_a?(String) && key.match?(/\A\d+\z/))
363
+
240
364
  return Bridge::UNHANDLED
241
365
  end
242
366