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
data/lib/dommy/fetch.rb
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
# `fetch` polyfill. No real network — instead consults
|
|
7
|
+
# `JS.global[:__fetchy_stub__]` (a Hash{url => entry}) installed by
|
|
8
|
+
# the test. Mirrors the same fixture protocol that `test_fetchy.rb`'s
|
|
9
|
+
# JavaScript installer uses, so tests don't need a JS engine to drive
|
|
10
|
+
# the stub.
|
|
11
|
+
#
|
|
12
|
+
# Each entry in the stub hash supports:
|
|
13
|
+
# "status" / "statusText" / "body" / "contentType" /
|
|
14
|
+
# "headers" (Hash) / "delay" (ms)
|
|
15
|
+
# plus AbortController signal propagation when `init[:signal]` is
|
|
16
|
+
# passed.
|
|
17
|
+
class FetchFn
|
|
18
|
+
def initialize(window)
|
|
19
|
+
@window = window
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# JS calls `fetch(url, init)` end up here via either Window-level
|
|
23
|
+
# `__js_call__("fetch", ...)` or as a callable handle. Both routes
|
|
24
|
+
# delegate to `call(args)` so behavior is identical.
|
|
25
|
+
def __js_call__(_method, args)
|
|
26
|
+
url = args[0].to_s
|
|
27
|
+
init = normalize_init(args[1] || {})
|
|
28
|
+
|
|
29
|
+
# Each spec file installs its stub under its own global name.
|
|
30
|
+
# `test_fetchy.rb` uses `__fetchy_stub__`; `test_resource*.rb`
|
|
31
|
+
# use `__resource_fetch_stub__` and `__inject_fetch_stub__`.
|
|
32
|
+
# Check them in order — only one should be set at a time.
|
|
33
|
+
stub_map = @window.globals["__fetchy_stub__"] ||
|
|
34
|
+
@window.globals["__resource_fetch_stub__"] ||
|
|
35
|
+
@window.globals["__inject_fetch_stub__"] ||
|
|
36
|
+
{}
|
|
37
|
+
# `js_eval`'s JS installer increments these globals; mirror so
|
|
38
|
+
# specs that probe `__fetch_count__` / `__last_url__` / etc.
|
|
39
|
+
# observe the same state shape they'd see from a real injector.
|
|
40
|
+
@window.globals["__fetch_count__"] = (@window.globals["__fetch_count__"] || 0).to_i + 1
|
|
41
|
+
@window.globals["__last_url__"] = url
|
|
42
|
+
@window.globals["__last_init__"] = init
|
|
43
|
+
@window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
entry = stub_map[url] if stub_map.is_a?(Hash)
|
|
46
|
+
promise = PromiseValue.new(@window)
|
|
47
|
+
|
|
48
|
+
if entry.nil?
|
|
49
|
+
response = Response.new(@window, body: "not found", status: 404, status_text: "Not Found")
|
|
50
|
+
promise.fulfill(response)
|
|
51
|
+
return promise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
body = entry["body"]
|
|
55
|
+
status = (entry["status"] || 200).to_i
|
|
56
|
+
status_text = entry["statusText"] || ""
|
|
57
|
+
content_type = entry["contentType"] || "text/plain"
|
|
58
|
+
headers = entry["headers"] || {"Content-Type" => content_type}
|
|
59
|
+
|
|
60
|
+
delay = entry["delay"]
|
|
61
|
+
if delay
|
|
62
|
+
install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
|
|
63
|
+
else
|
|
64
|
+
promise.fulfill(
|
|
65
|
+
Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, url: url)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
promise
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Coerce `init` into a Hash with string keys so the rest of the
|
|
75
|
+
# pipeline (and the `__last_init__` globals) sees a uniform shape.
|
|
76
|
+
# When the body is a Blob/File, fill in `Content-Type` from the
|
|
77
|
+
# blob's type unless the caller already provided a header for it.
|
|
78
|
+
def normalize_init(init)
|
|
79
|
+
return init unless init.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
h = init.transform_keys(&:to_s)
|
|
82
|
+
body = h["body"]
|
|
83
|
+
return h unless body.is_a?(Blob)
|
|
84
|
+
|
|
85
|
+
headers = (h["headers"] || {}).dup
|
|
86
|
+
content_type_set = headers.any? { |k, _| k.to_s.downcase == "content-type" }
|
|
87
|
+
headers["Content-Type"] = body.type if !content_type_set && !body.type.empty?
|
|
88
|
+
h["headers"] = headers
|
|
89
|
+
h
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def install_delayed_resolve(promise, body, status, status_text, headers, init, delay_ms)
|
|
93
|
+
# AbortController cancellation: when init.signal is present and
|
|
94
|
+
# `.abort()` fires before the timer, reject with an AbortError.
|
|
95
|
+
# The timer is cleared in that path so it doesn't leak through
|
|
96
|
+
# the test scheduler's drain loop.
|
|
97
|
+
cancelled = [false]
|
|
98
|
+
timer_id = @window.scheduler.set_timeout(
|
|
99
|
+
lambda do |*_args|
|
|
100
|
+
next if cancelled[0]
|
|
101
|
+
|
|
102
|
+
promise.fulfill(Response.new(@window, body: body, status: status, status_text: status_text, headers: headers))
|
|
103
|
+
end,
|
|
104
|
+
delay_ms.to_i
|
|
105
|
+
)
|
|
106
|
+
signal = init.is_a?(Hash) ? init["signal"] : nil
|
|
107
|
+
return unless signal.respond_to?(:__js_call__)
|
|
108
|
+
|
|
109
|
+
window_ref = @window
|
|
110
|
+
abort_cb = lambda do |*_args|
|
|
111
|
+
cancelled[0] = true
|
|
112
|
+
window_ref.scheduler.clear_timeout(timer_id)
|
|
113
|
+
err = ErrorValue.new("aborted", name: "AbortError")
|
|
114
|
+
promise.reject(err)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
signal.__js_call__("addEventListener", ["abort", abort_cb])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `Response` polyfill — just enough surface for Fetchy:
|
|
122
|
+
# `[:status]` / `[:ok]` / `[:url]` / `[:headers]` (with
|
|
123
|
+
# `.entries()` / `.get(name)`) and `.text()` / `.json()` / `.body`
|
|
124
|
+
# / `.arrayBuffer()` which all return Promise-like values.
|
|
125
|
+
class Response
|
|
126
|
+
def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "")
|
|
127
|
+
@window = window
|
|
128
|
+
@body = body.to_s
|
|
129
|
+
@status = status
|
|
130
|
+
@status_text = status_text.to_s
|
|
131
|
+
@headers = Headers.new(headers || {})
|
|
132
|
+
@url = url.to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def __js_get__(key)
|
|
136
|
+
case key
|
|
137
|
+
when "status"
|
|
138
|
+
@status
|
|
139
|
+
when "ok"
|
|
140
|
+
@status >= 200 && @status < 300
|
|
141
|
+
when "statusText"
|
|
142
|
+
@status_text
|
|
143
|
+
when "url"
|
|
144
|
+
@url
|
|
145
|
+
when "headers"
|
|
146
|
+
@headers
|
|
147
|
+
when "body"
|
|
148
|
+
@body
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def __js_set__(_key, _value)
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def __js_call__(method, _args)
|
|
157
|
+
case method
|
|
158
|
+
when "text"
|
|
159
|
+
immediate(@body)
|
|
160
|
+
when "json"
|
|
161
|
+
begin
|
|
162
|
+
immediate(JSON.parse(@body))
|
|
163
|
+
rescue JSON::ParserError => e
|
|
164
|
+
err = ErrorValue.new("JSON parse: #{e.message}")
|
|
165
|
+
rejected(err)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
when "arrayBuffer", "blob"
|
|
169
|
+
immediate(@body)
|
|
170
|
+
when "clone"
|
|
171
|
+
Response.new(
|
|
172
|
+
@window,
|
|
173
|
+
body: @body,
|
|
174
|
+
status: @status,
|
|
175
|
+
status_text: @status_text,
|
|
176
|
+
headers: @headers.to_h,
|
|
177
|
+
url: @url
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def immediate(value)
|
|
185
|
+
PromiseValue.resolve(@window, value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def rejected(value)
|
|
189
|
+
PromiseValue.reject(@window, value)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Minimal `Headers` proxy. Consumer code typically calls
|
|
194
|
+
# `headers.call(:entries)` and iterates via `Array.from(...)`, so
|
|
195
|
+
# we just need `entries` and `get`.
|
|
196
|
+
class Headers
|
|
197
|
+
def initialize(hash)
|
|
198
|
+
@hash = hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def to_h
|
|
202
|
+
@hash.dup
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def __js_get__(_key)
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def __js_set__(_key, _value)
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def __js_call__(method, args)
|
|
214
|
+
case method
|
|
215
|
+
when "get"
|
|
216
|
+
name = args[0].to_s
|
|
217
|
+
@hash[name] || @hash[Headers.canonical(name)]
|
|
218
|
+
when "entries"
|
|
219
|
+
@hash.to_a
|
|
220
|
+
when "has"
|
|
221
|
+
@hash.key?(args[0].to_s)
|
|
222
|
+
when "forEach"
|
|
223
|
+
# Browser API: forEach(callback) — callback(value, key)
|
|
224
|
+
cb = args[0]
|
|
225
|
+
@hash.each do |k, v|
|
|
226
|
+
if cb.respond_to?(:__js_call__)
|
|
227
|
+
cb.__js_call__("call", [v, k])
|
|
228
|
+
elsif cb.respond_to?(:call)
|
|
229
|
+
cb.call(v, k)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.canonical(name)
|
|
238
|
+
name.split("-").map(&:capitalize).join("-")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `FormData` — collects name/value entries from an `<form>` (or
|
|
5
|
+
# programmatically), preserving insertion order. Values are
|
|
6
|
+
# stringified per spec; `File` values are passed through as-is
|
|
7
|
+
# (Dommy has no File class, so this only matters for embedders
|
|
8
|
+
# that supply their own).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# fd = Dommy::FormData.new(form)
|
|
12
|
+
# fd.get("email") # "alice@x.test"
|
|
13
|
+
# fd.append("tag", "ruby")
|
|
14
|
+
# fd.entries # [["email", "..."], ["tag", "ruby"]]
|
|
15
|
+
class FormData
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
def initialize(form = nil)
|
|
19
|
+
@pairs = []
|
|
20
|
+
collect_from(form) if form
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def append(name, value, _filename = nil)
|
|
24
|
+
@pairs << [name.to_s, stringify(value)]
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def set(name, value, _filename = nil)
|
|
29
|
+
key = name.to_s
|
|
30
|
+
v = stringify(value)
|
|
31
|
+
replaced = false
|
|
32
|
+
@pairs = @pairs.flat_map do |k, existing|
|
|
33
|
+
if k == key
|
|
34
|
+
if replaced
|
|
35
|
+
[]
|
|
36
|
+
else
|
|
37
|
+
replaced = true
|
|
38
|
+
[[key, v]]
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
[[k, existing]]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@pairs << [key, v] unless replaced
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get(name)
|
|
50
|
+
pair = @pairs.find { |k, _| k == name.to_s }
|
|
51
|
+
pair && pair[1]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_all(name)
|
|
55
|
+
@pairs.select { |k, _| k == name.to_s }.map { |_, v| v }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
alias getAll get_all
|
|
59
|
+
|
|
60
|
+
def has(name)
|
|
61
|
+
@pairs.any? { |k, _| k == name.to_s }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
alias has? has
|
|
65
|
+
|
|
66
|
+
def delete(name)
|
|
67
|
+
@pairs.reject! { |k, _| k == name.to_s }
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def keys
|
|
72
|
+
@pairs.map { |k, _| k }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def values
|
|
76
|
+
@pairs.map { |_, v| v }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def entries
|
|
80
|
+
@pairs.dup
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def for_each(&block)
|
|
84
|
+
@pairs.each { |k, v| block.call(v, k, self) }
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
alias forEach for_each
|
|
89
|
+
|
|
90
|
+
def each(&block)
|
|
91
|
+
@pairs.each(&block)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def size
|
|
95
|
+
@pairs.length
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias length size
|
|
99
|
+
|
|
100
|
+
def to_s
|
|
101
|
+
@pairs.map { |k, v| "#{k}=#{v}" }.join("&")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def __js_get__(key)
|
|
105
|
+
case key
|
|
106
|
+
when "size", "length"
|
|
107
|
+
size
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def __js_call__(method, args)
|
|
112
|
+
case method
|
|
113
|
+
when "append"
|
|
114
|
+
append(args[0], args[1], args[2])
|
|
115
|
+
when "set"
|
|
116
|
+
set(args[0], args[1], args[2])
|
|
117
|
+
when "get"
|
|
118
|
+
get(args[0])
|
|
119
|
+
when "getAll"
|
|
120
|
+
get_all(args[0])
|
|
121
|
+
when "has"
|
|
122
|
+
has(args[0])
|
|
123
|
+
when "delete"
|
|
124
|
+
delete(args[0])
|
|
125
|
+
when "keys"
|
|
126
|
+
keys
|
|
127
|
+
when "values"
|
|
128
|
+
values
|
|
129
|
+
when "entries"
|
|
130
|
+
entries
|
|
131
|
+
when "forEach"
|
|
132
|
+
for_each(&args[0])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Collect submittable name/value pairs from a form element.
|
|
139
|
+
# Per spec, the submitter (clicked button) is included only when
|
|
140
|
+
# the user passes it explicitly; we don't model that here.
|
|
141
|
+
def collect_from(form)
|
|
142
|
+
form.elements.each do |el|
|
|
143
|
+
next unless el.respond_to?(:name)
|
|
144
|
+
|
|
145
|
+
name = el.name.to_s
|
|
146
|
+
next if name.empty?
|
|
147
|
+
next if disabled?(el)
|
|
148
|
+
|
|
149
|
+
case el.__node__.name
|
|
150
|
+
when "input"
|
|
151
|
+
collect_input(el, name)
|
|
152
|
+
when "select"
|
|
153
|
+
collect_select(el, name)
|
|
154
|
+
when "textarea", "button", "output"
|
|
155
|
+
@pairs << [name, el.value.to_s] if el.respond_to?(:value)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def collect_input(el, name)
|
|
161
|
+
type = el.type.to_s.downcase
|
|
162
|
+
case type
|
|
163
|
+
when "submit", "reset", "button", "image"
|
|
164
|
+
# submit/button: only the activated submitter is included (skip).
|
|
165
|
+
nil
|
|
166
|
+
when "file"
|
|
167
|
+
# Each File in the input's FileList becomes its own entry, per
|
|
168
|
+
# the HTML "constructing the entry list" spec. An empty list
|
|
169
|
+
# contributes a single empty File-like entry so name= survives.
|
|
170
|
+
files = el.respond_to?(:files) ? el.files : nil
|
|
171
|
+
if files && !files.empty?
|
|
172
|
+
files.each { |f| @pairs << [name, f] }
|
|
173
|
+
else
|
|
174
|
+
@pairs << [name, File.new([], "", "type" => "application/octet-stream")]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
when "checkbox", "radio"
|
|
178
|
+
@pairs << [name, (el.value.to_s.empty? ? "on" : el.value.to_s)] if el.checked
|
|
179
|
+
else
|
|
180
|
+
@pairs << [name, el.value.to_s]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def collect_select(el, name)
|
|
185
|
+
if el.multiple
|
|
186
|
+
el.selected_options.each { |opt| @pairs << [name, opt.value.to_s] }
|
|
187
|
+
else
|
|
188
|
+
opt = el.selected_options[0]
|
|
189
|
+
@pairs << [name, opt ? opt.value.to_s : ""]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def disabled?(el)
|
|
194
|
+
el.respond_to?(:disabled) && el.disabled
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def stringify(value)
|
|
198
|
+
# File / Blob values pass through unchanged (multipart form
|
|
199
|
+
# encoding handles them); other values are stringified per spec.
|
|
200
|
+
return value if value.is_a?(Blob)
|
|
201
|
+
# Backward-compat: embedders' own file-marker objects.
|
|
202
|
+
return value if value.respond_to?(:__file_marker__)
|
|
203
|
+
return "" if value.nil?
|
|
204
|
+
|
|
205
|
+
value.to_s
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `HTMLCollection` — live, ordered set of Element nodes. Distinct
|
|
5
|
+
# from `NodeList` in two ways:
|
|
6
|
+
#
|
|
7
|
+
# - Always element-only (Node types other than Element are skipped)
|
|
8
|
+
# - Supports `namedItem(name)` lookup by `id` or `name` attribute
|
|
9
|
+
#
|
|
10
|
+
# Live behavior: pass an evaluator block (called `&compute`) that
|
|
11
|
+
# returns the current element list on every access. Each query
|
|
12
|
+
# re-evaluates, so mutations to the parent tree are reflected
|
|
13
|
+
# immediately.
|
|
14
|
+
#
|
|
15
|
+
# Intentionally NOT a subclass of Array; spec semantics demand
|
|
16
|
+
# `Array.isArray(html_collection) === false` in real browsers, and
|
|
17
|
+
# mirroring that here helps tests written against MDN behavior.
|
|
18
|
+
class HTMLCollection
|
|
19
|
+
include Enumerable
|
|
20
|
+
|
|
21
|
+
def initialize(&compute)
|
|
22
|
+
@compute = compute
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def length
|
|
26
|
+
to_a.length
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias size length
|
|
30
|
+
|
|
31
|
+
def empty?
|
|
32
|
+
to_a.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def item(index)
|
|
36
|
+
i = index.to_i
|
|
37
|
+
return nil if i < 0
|
|
38
|
+
|
|
39
|
+
to_a[i]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `namedItem(name)` returns the first element whose `id` or
|
|
43
|
+
# `name` attribute equals `name`. Returns nil if no match.
|
|
44
|
+
def named_item(name)
|
|
45
|
+
key = name.to_s
|
|
46
|
+
return nil if key.empty?
|
|
47
|
+
|
|
48
|
+
to_a.find do |el|
|
|
49
|
+
next false unless el.respond_to?(:__node__)
|
|
50
|
+
|
|
51
|
+
el.__node__["id"].to_s == key || el.__node__["name"].to_s == key
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `[]` supports both integer index (`coll[0]`, `coll[-1]`) and
|
|
56
|
+
# string name (`coll["myId"]`). Negative indices are interpreted
|
|
57
|
+
# Ruby-style (offset from the end), even though the spec's
|
|
58
|
+
# `item(i)` is positive-only.
|
|
59
|
+
def [](key)
|
|
60
|
+
case key
|
|
61
|
+
when Integer
|
|
62
|
+
to_a[key]
|
|
63
|
+
when /\A-?\d+\z/
|
|
64
|
+
to_a[key.to_i]
|
|
65
|
+
else
|
|
66
|
+
named_item(key)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def first(n = nil)
|
|
71
|
+
n.nil? ? to_a.first : to_a.first(n)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def last(n = nil)
|
|
75
|
+
n.nil? ? to_a.last : to_a.last(n)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def each(&blk)
|
|
79
|
+
to_a.each(&blk)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_a
|
|
83
|
+
@compute.call
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def __js_get__(key)
|
|
87
|
+
case key
|
|
88
|
+
when "length"
|
|
89
|
+
length
|
|
90
|
+
when Integer
|
|
91
|
+
item(key)
|
|
92
|
+
else
|
|
93
|
+
s = key.to_s
|
|
94
|
+
if s.match?(/\A\d+\z/)
|
|
95
|
+
item(s.to_i)
|
|
96
|
+
else
|
|
97
|
+
named_item(s) || (s == "length" ? length : nil)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def __js_call__(method, args)
|
|
103
|
+
case method
|
|
104
|
+
when "item"
|
|
105
|
+
item(args[0])
|
|
106
|
+
when "namedItem"
|
|
107
|
+
named_item(args[0])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `HTMLOptionsCollection` — specialized `<select>.options` collection.
|
|
113
|
+
# Adds `add(option, before?)`, `remove(index)`, the `selectedIndex`
|
|
114
|
+
# getter/setter, and a `length=` setter that truncates or extends.
|
|
115
|
+
#
|
|
116
|
+
# Live, like the parent class. Constructed by `HTMLSelectElement`
|
|
117
|
+
# and passed its owner; mutations route through the owner's tree.
|
|
118
|
+
class HTMLOptionsCollection < HTMLCollection
|
|
119
|
+
def initialize(owner, &compute)
|
|
120
|
+
super(&compute)
|
|
121
|
+
@owner = owner
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Append (or insert before `before`) an option element. `before`
|
|
125
|
+
# accepts either another option (insert before that node) or an
|
|
126
|
+
# integer index. Strings/`null` append.
|
|
127
|
+
def add(option, before = nil)
|
|
128
|
+
return nil unless option.respond_to?(:__node__)
|
|
129
|
+
|
|
130
|
+
case before
|
|
131
|
+
when nil
|
|
132
|
+
@owner.append_child(option)
|
|
133
|
+
when Integer
|
|
134
|
+
anchor = item(before)
|
|
135
|
+
anchor ? @owner.insert_before(option, anchor) : @owner.append_child(option)
|
|
136
|
+
else
|
|
137
|
+
if before.respond_to?(:__node__)
|
|
138
|
+
@owner.insert_before(option, before)
|
|
139
|
+
else
|
|
140
|
+
@owner.append_child(option)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def remove(index)
|
|
148
|
+
target = item(index)
|
|
149
|
+
target&.remove
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def selected_index
|
|
154
|
+
@owner.selected_index
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def selected_index=(value)
|
|
158
|
+
@owner.selected_index = value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Setter mirrors `<select>.options.length = n` — destructive resize.
|
|
162
|
+
# Shrinks by removing trailing options, grows by appending blank
|
|
163
|
+
# `<option>`s. Real browsers do the same.
|
|
164
|
+
def length=(new_length)
|
|
165
|
+
n = new_length.to_i
|
|
166
|
+
current = to_a
|
|
167
|
+
if n < current.length
|
|
168
|
+
current[n..].each(&:remove)
|
|
169
|
+
elsif n > current.length
|
|
170
|
+
(n - current.length).times { @owner.append_child(@owner.document.create_element("option")) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
n
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def __js_get__(key)
|
|
177
|
+
case key
|
|
178
|
+
when "selectedIndex"
|
|
179
|
+
selected_index
|
|
180
|
+
else
|
|
181
|
+
super
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def __js_set__(key, value)
|
|
186
|
+
case key
|
|
187
|
+
when "selectedIndex"
|
|
188
|
+
self.selected_index = value
|
|
189
|
+
when "length"
|
|
190
|
+
self.length = value
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def __js_call__(method, args)
|
|
197
|
+
case method
|
|
198
|
+
when "add"
|
|
199
|
+
add(args[0], args[1])
|
|
200
|
+
when "remove"
|
|
201
|
+
remove(args[0])
|
|
202
|
+
else
|
|
203
|
+
super
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|