dommy 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/dommy/attr.rb +46 -8
- data/lib/dommy/backend/makiri_adapter.rb +34 -0
- data/lib/dommy/backend.rb +29 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +239 -36
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/document.rb +525 -57
- data/lib/dommy/element.rb +479 -239
- data/lib/dommy/event.rb +296 -15
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +145 -21
- data/lib/dommy/html_elements.rb +1675 -266
- data/lib/dommy/interaction/driver.rb +155 -14
- data/lib/dommy/interaction/event_synthesis.rb +115 -7
- data/lib/dommy/interaction/field_interactor.rb +60 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/cascade.rb +44 -2
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/mutation_coordinator.rb +27 -7
- data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +37 -73
- data/lib/dommy/internal/selector_matcher.rb +89 -0
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +19 -4
- data/lib/dommy/js/host_bridge.rb +21 -0
- data/lib/dommy/js/host_runtime.js +926 -64
- data/lib/dommy/js/script_boot.rb +80 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +3 -4
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +180 -27
- data/lib/dommy/range.rb +15 -3
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +33 -0
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +18 -36
- data/lib/dommy/url.rb +4 -2
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +126 -7
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +2 -0
- metadata +6 -10
data/lib/dommy/html_elements.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
3
5
|
module Dommy
|
|
4
6
|
# Base for specialized HTMLElement subclasses. Inherits reflection
|
|
5
7
|
# helpers from Internal::ReflectedAttributes (also shared with
|
|
@@ -7,15 +9,74 @@ module Dommy
|
|
|
7
9
|
class HTMLElement < Element
|
|
8
10
|
include Internal::ReflectedAttributes
|
|
9
11
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
def
|
|
12
|
+
# WHATWG "actually disabled": a form control is disabled if it (or an
|
|
13
|
+
# ancestor <fieldset disabled>) is disabled — EXCEPT a control within that
|
|
14
|
+
# fieldset's first <legend> child is NOT disabled by the fieldset. This drives
|
|
15
|
+
# willValidate / constraint validation (the `:disabled` selector has its own
|
|
16
|
+
# equivalent in SelectorMatcher).
|
|
17
|
+
def disabled_by_ancestor_fieldset?
|
|
18
|
+
node = parent_element
|
|
19
|
+
while node
|
|
20
|
+
if node.local_name.to_s.casecmp?("fieldset") && node.has_attribute?("disabled")
|
|
21
|
+
legend = node.child_nodes.to_a.find do |c|
|
|
22
|
+
c.respond_to?(:local_name) && c.local_name.to_s.casecmp?("legend")
|
|
23
|
+
end
|
|
24
|
+
return false if legend&.contains?(self)
|
|
25
|
+
|
|
26
|
+
return true
|
|
27
|
+
end
|
|
28
|
+
node = node.parent_element
|
|
29
|
+
end
|
|
16
30
|
false
|
|
17
31
|
end
|
|
18
32
|
|
|
33
|
+
# Shared "limited to only non-negative numbers" long reflection (maxLength /
|
|
34
|
+
# minLength on input and textarea): a missing / negative / non-numeric
|
|
35
|
+
# content attribute reads as -1; assigning a negative value throws.
|
|
36
|
+
def parse_non_negative_reflected(attr)
|
|
37
|
+
raw = @__node__[attr]
|
|
38
|
+
return -1 if raw.nil?
|
|
39
|
+
# HTML "rules for parsing non-negative integers": leading ASCII whitespace,
|
|
40
|
+
# then digits; anything else (a sign, letters) is an error → -1.
|
|
41
|
+
m = raw.to_s.match(/\A[\t\n\f\r ]*(\d+)/)
|
|
42
|
+
m ? m[1].to_i : -1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_non_negative_reflected(attr, value)
|
|
46
|
+
n = value.to_i
|
|
47
|
+
raise DOMException::IndexSizeError, "#{attr} must be non-negative" if n.negative?
|
|
48
|
+
|
|
49
|
+
set_reflected_string(attr, n.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# HTML attribute names are case-insensitive only in an HTML document — the
|
|
53
|
+
# browser DOM lowercases everything there. In a non-HTML (XML) document even an
|
|
54
|
+
# HTML-namespaced element preserves case. Shortcuts Element's namespace check
|
|
55
|
+
# for HTML's hot path while honoring the document-kind condition.
|
|
56
|
+
def case_sensitive_attribute_names?
|
|
57
|
+
!@document.html_document?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The `labels` NodeList for a labelable control: every <label> in the
|
|
61
|
+
# document whose labeled `control` resolves to this element — via an explicit
|
|
62
|
+
# `for=` reference OR by wrapping it as the label's first labelable
|
|
63
|
+
# descendant (so nested/ancestor labels count). Shared by button, input,
|
|
64
|
+
# meter, output, progress, select, and textarea. Live, so a retained
|
|
65
|
+
# reference reflects later DOM/type changes (e.g. an input turning `hidden`
|
|
66
|
+
# drops out of its labels). Memoized so it is the [SameObject] across reads.
|
|
67
|
+
def labels_node_list
|
|
68
|
+
el = self
|
|
69
|
+
@__labels_node_list ||= LiveNodeList.new do
|
|
70
|
+
me = el.__dommy_backend_node__
|
|
71
|
+
el.document.query_selector_all("label").select do |label|
|
|
72
|
+
next false unless label.respond_to?(:control)
|
|
73
|
+
|
|
74
|
+
c = label.control
|
|
75
|
+
c.respond_to?(:__dommy_backend_node__) && c.__dommy_backend_node__.equal?(me)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
19
80
|
private
|
|
20
81
|
|
|
21
82
|
# HTML "rules for parsing integers": optional leading ASCII whitespace, an
|
|
@@ -32,8 +93,90 @@ module Dommy
|
|
|
32
93
|
|
|
33
94
|
# `<a>` — exposes URL-component getters/setters via the `href`
|
|
34
95
|
# attribute, plus reflected `target` / `download` / `rel` / `type`.
|
|
96
|
+
# Follow-the-hyperlink activation behavior shared by <a> and <area>. A
|
|
97
|
+
# non-canceled click on a hyperlink (with an href, no download attribute)
|
|
98
|
+
# navigates to its resolved URL: a same-document fragment change fires
|
|
99
|
+
# hashchange and updates :target; anything else is handed to the navigation
|
|
100
|
+
# delegate (which performs the real navigation, or records it by default).
|
|
101
|
+
module HyperlinkActivation
|
|
102
|
+
def activation_target?
|
|
103
|
+
has_attribute?("href")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def activation_behavior(_event)
|
|
107
|
+
return unless has_attribute?("href")
|
|
108
|
+
# download turns the click into a save, not a navigation — out of scope.
|
|
109
|
+
return if has_attribute?("download")
|
|
110
|
+
|
|
111
|
+
target = anchor_href
|
|
112
|
+
win = @document&.default_view
|
|
113
|
+
return if target.to_s.empty? || win.nil? || win.location.nil?
|
|
114
|
+
|
|
115
|
+
# A cross-document link hands off to the delegate without pre-mutating the
|
|
116
|
+
# location; a same-document fragment still updates the hash + :target.
|
|
117
|
+
win.location.__internal_navigate_to__(target, source: :link, sync_cross_doc: false)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The activation behavior of a submit button: run the owning form's
|
|
122
|
+
# submission algorithm with this button as the submitter. This makes a click
|
|
123
|
+
# — a real user click, a synthesized driver click, or `button.click()` from
|
|
124
|
+
# JS — on a submit button submit its form (fire a SubmitEvent, then hand
|
|
125
|
+
# navigation to the delegate), with no driver-level special-casing. Mirrors
|
|
126
|
+
# HyperlinkActivation for the form side.
|
|
127
|
+
module SubmitButtonActivation
|
|
128
|
+
def activation_target?
|
|
129
|
+
__submit_button__?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def activation_behavior(_event)
|
|
133
|
+
return unless __submit_button__?
|
|
134
|
+
|
|
135
|
+
# `form` follows the form-owner algorithm (honoring a `form=` attribute) on
|
|
136
|
+
# both input and button, so a form-associated submit button outside its
|
|
137
|
+
# form still submits the right one.
|
|
138
|
+
form&.__run_form_submission__(self)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The "Window-reflecting body element event handler set": setting one of these
|
|
143
|
+
# event handler IDL attributes on <body>/<frameset> (`body.onload = fn`)
|
|
144
|
+
# actually targets the WINDOW, per HTML — so `window.onload` fires. A
|
|
145
|
+
# non-reflected handler (`body.onclick`) stays on the element.
|
|
146
|
+
module WindowReflectingHandlers
|
|
147
|
+
REFLECTED_HANDLERS = %w[
|
|
148
|
+
onblur onerror onfocus onload onresize onscroll onafterprint onbeforeprint
|
|
149
|
+
onbeforeunload onhashchange onlanguagechange onmessage onmessageerror onoffline
|
|
150
|
+
ononline onpagehide onpageshow onpopstate onrejectionhandled onstorage
|
|
151
|
+
onunhandledrejection onunload
|
|
152
|
+
].to_set.freeze
|
|
153
|
+
|
|
154
|
+
def __js_set__(key, value)
|
|
155
|
+
if key.is_a?(String) && REFLECTED_HANDLERS.include?(key) && (win = @document&.default_view)
|
|
156
|
+
return win.__js_set__(key, value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def __js_get__(key)
|
|
163
|
+
if key.is_a?(String) && REFLECTED_HANDLERS.include?(key) && (win = @document&.default_view)
|
|
164
|
+
return win.__js_get__(key)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
super
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
35
171
|
class HTMLAnchorElement < HTMLElement
|
|
172
|
+
include HyperlinkActivation
|
|
36
173
|
reflect_string :target, :download, :rel, :hreflang, :type
|
|
174
|
+
|
|
175
|
+
# WebIDL stringifier: `String(anchor)` / `anchor.toString()` is its href (the
|
|
176
|
+
# resolved absolute URL), not the element's serialization.
|
|
177
|
+
def to_s
|
|
178
|
+
anchor_href
|
|
179
|
+
end
|
|
37
180
|
# URL-decomposition helpers. The anchor's `href` is resolved to
|
|
38
181
|
# an absolute URL (inherited from Element#anchor_href); break it
|
|
39
182
|
# into the standard components on demand.
|
|
@@ -69,6 +212,15 @@ module Dommy
|
|
|
69
212
|
uri.scheme && uri.host ? "#{uri.scheme}://#{uri.host}#{port_suffix}" : ""
|
|
70
213
|
end
|
|
71
214
|
|
|
215
|
+
# `a.text` is an alias for the element's descendant text content.
|
|
216
|
+
def text
|
|
217
|
+
text_content
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def text=(v)
|
|
221
|
+
self.text_content = v.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
72
224
|
def __js_get__(key)
|
|
73
225
|
case key
|
|
74
226
|
when "hash"
|
|
@@ -87,6 +239,17 @@ module Dommy
|
|
|
87
239
|
port
|
|
88
240
|
when "origin"
|
|
89
241
|
origin
|
|
242
|
+
when "text"
|
|
243
|
+
text
|
|
244
|
+
else
|
|
245
|
+
super
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def __js_set__(key, value)
|
|
250
|
+
case key
|
|
251
|
+
when "text"
|
|
252
|
+
self.text = value
|
|
90
253
|
else
|
|
91
254
|
super
|
|
92
255
|
end
|
|
@@ -126,28 +289,50 @@ module Dommy
|
|
|
126
289
|
# input/select/textarea/button/output/fieldset). Returned as a
|
|
127
290
|
# live HTMLCollection so listening to `submit`/`reset` and
|
|
128
291
|
# adding fields between accesses works as expected.
|
|
292
|
+
# `form.elements` — the listed controls owned by this form, in document tree
|
|
293
|
+
# order, excluding input type=image. Membership follows the WHATWG form-owner
|
|
294
|
+
# algorithm (a `form` content attribute overrides DOM nesting), NOT plain
|
|
295
|
+
# descendant containment, so controls associated via `form=id` are included
|
|
296
|
+
# and a control in a nested inner form is excluded. Memoized so the live
|
|
297
|
+
# collection is the [SameObject] each access returns.
|
|
298
|
+
LISTED_CONTROL_SELECTOR = "input, select, textarea, button, output, fieldset, object"
|
|
299
|
+
|
|
129
300
|
def elements
|
|
130
301
|
el = self
|
|
131
|
-
|
|
132
|
-
el
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
137
|
-
end
|
|
138
|
-
.compact
|
|
302
|
+
@elements ||= HTMLFormControlsCollection.new do
|
|
303
|
+
el.document.query_selector_all(LISTED_CONTROL_SELECTOR).select do |c|
|
|
304
|
+
next false if c.tag_name.to_s.casecmp?("input") && c.respond_to?(:type) && c.type.to_s.casecmp?("image")
|
|
305
|
+
|
|
306
|
+
el.__owns_control__(c)
|
|
307
|
+
end
|
|
139
308
|
end
|
|
140
309
|
end
|
|
141
310
|
|
|
311
|
+
# The form owner of a listed control, per WHATWG: when the control carries a
|
|
312
|
+
# `form` content attribute, its owner is the form element with that id (or
|
|
313
|
+
# nothing, if the id resolves to a non-form / nothing); otherwise it is the
|
|
314
|
+
# nearest ancestor form element.
|
|
315
|
+
def __owns_control__(control)
|
|
316
|
+
form_id = control.__dommy_backend_node__["form"].to_s
|
|
317
|
+
owner =
|
|
318
|
+
if form_id.empty?
|
|
319
|
+
control.closest("form")
|
|
320
|
+
else
|
|
321
|
+
target = control.document.get_element_by_id(form_id)
|
|
322
|
+
(target && target.tag_name.to_s.casecmp?("form")) ? target : nil
|
|
323
|
+
end
|
|
324
|
+
!owner.nil? && owner.__dommy_backend_node__.equal?(__dommy_backend_node__)
|
|
325
|
+
end
|
|
326
|
+
|
|
142
327
|
def length
|
|
143
328
|
elements.size
|
|
144
329
|
end
|
|
145
330
|
|
|
146
|
-
# Spec: `submit()` performs form submission directly WITHOUT
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
# navigation engine, so this is effectively a no-op (returns nil).
|
|
331
|
+
# Spec: `submit()` performs form submission directly WITHOUT firing a
|
|
332
|
+
# `submit` event and without constraint validation. Navigation is handed to
|
|
333
|
+
# the delegate (a no-op recording by default).
|
|
150
334
|
def submit
|
|
335
|
+
__internal_navigate_for_submit__(nil)
|
|
151
336
|
nil
|
|
152
337
|
end
|
|
153
338
|
|
|
@@ -158,10 +343,11 @@ module Dommy
|
|
|
158
343
|
dispatch_event(Event.new("reset", "bubbles" => true, "cancelable" => true))
|
|
159
344
|
end
|
|
160
345
|
|
|
161
|
-
# Spec: `requestSubmit(submitter?)`
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
# `submitter` (if given) must be a button inside
|
|
346
|
+
# Spec: `requestSubmit(submitter?)` MIRRORS user-initiated submission — it
|
|
347
|
+
# fires a `submit` event (with the submitter), and on a non-canceled event
|
|
348
|
+
# hands the form navigation to the delegate. Returns true if not
|
|
349
|
+
# default-prevented. `submitter` (if given) must be a submit button inside
|
|
350
|
+
# this form.
|
|
165
351
|
def request_submit(submitter = nil)
|
|
166
352
|
if submitter
|
|
167
353
|
unless submitter.respond_to?(:__dommy_backend_node__) && submitter.__dommy_backend_node__.ancestors.include?(@__node__)
|
|
@@ -174,7 +360,38 @@ module Dommy
|
|
|
174
360
|
end
|
|
175
361
|
end
|
|
176
362
|
|
|
177
|
-
|
|
363
|
+
__run_form_submission__(submitter)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The form submission algorithm's observable core, shared by
|
|
367
|
+
# `requestSubmit()`, a submit button's activation (driver click), and Enter's
|
|
368
|
+
# implicit submission: fire a cancelable `SubmitEvent` (carrying the
|
|
369
|
+
# submitter), and — when nothing canceled it — hand the resulting navigation
|
|
370
|
+
# to the delegate. Returns true if not default-prevented. This is the single
|
|
371
|
+
# home for "a form was submitted"; callers that previously dispatched a bare
|
|
372
|
+
# `submit` event route here so the event is a real SubmitEvent (with
|
|
373
|
+
# submitter) and the navigation reaches the delegate.
|
|
374
|
+
def __run_form_submission__(submitter = nil)
|
|
375
|
+
not_canceled = dispatch_event(
|
|
376
|
+
SubmitEvent.new("submit", "bubbles" => true, "cancelable" => true, "submitter" => submitter)
|
|
377
|
+
)
|
|
378
|
+
__internal_navigate_for_submit__(submitter) if not_canceled
|
|
379
|
+
not_canceled
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Build the form data set and hand the resulting navigation to the delegate.
|
|
383
|
+
# Reuses the core FormSubmission serializer (submitter, method, action,
|
|
384
|
+
# enctype, GET query-stripping) — method-override is a host concern, so it's
|
|
385
|
+
# left off here (the delegate applies its own policy).
|
|
386
|
+
def __internal_navigate_for_submit__(submitter)
|
|
387
|
+
win = @document&.default_view
|
|
388
|
+
return if win.nil?
|
|
389
|
+
|
|
390
|
+
result = Dommy::Interaction::FormSubmission.new(self, submitter).submit!
|
|
391
|
+
win.__internal_navigate__(
|
|
392
|
+
url: result[:url], method: result[:method], params: result[:params],
|
|
393
|
+
enctype: result[:enctype], source: :form
|
|
394
|
+
)
|
|
178
395
|
end
|
|
179
396
|
|
|
180
397
|
# Walk all listed elements; the form is "valid" iff every
|
|
@@ -200,6 +417,12 @@ module Dommy
|
|
|
200
417
|
end
|
|
201
418
|
|
|
202
419
|
def __js_get__(key)
|
|
420
|
+
# HTMLFormElement is [LegacyOverrideBuiltIns]: a control whose name/id
|
|
421
|
+
# matches a builtin (`elements`, `length`, `submit`, `action`, …) shadows
|
|
422
|
+
# that builtin. So the named getter is consulted BEFORE the builtins.
|
|
423
|
+
named = named_controls[key.to_s]
|
|
424
|
+
return __named_getter_result__(key.to_s, named) if named && !named.empty?
|
|
425
|
+
|
|
203
426
|
case key
|
|
204
427
|
when "elements"
|
|
205
428
|
elements
|
|
@@ -210,6 +433,39 @@ module Dommy
|
|
|
210
433
|
end
|
|
211
434
|
end
|
|
212
435
|
|
|
436
|
+
# A single matching control is returned directly; multiple matches yield a
|
|
437
|
+
# RadioNodeList. The list is memoized per name and refreshed in place so
|
|
438
|
+
# repeated named-getter reads return the [SameObject] (WebIDL requires
|
|
439
|
+
# `form.d === form.d`), while still reflecting live membership.
|
|
440
|
+
def __named_getter_result__(name, matches)
|
|
441
|
+
return matches.first if matches.length == 1
|
|
442
|
+
|
|
443
|
+
form = self
|
|
444
|
+
(@__radio_lists ||= {})[name] ||= RadioNodeList.new { form.named_controls[name] || [] }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# WebIDL named getter: the form's supported property names are the name/id
|
|
448
|
+
# of each of its listed controls.
|
|
449
|
+
def __js_named_props__
|
|
450
|
+
named_controls.keys
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# name/id -> [controls], for the named getter (a name matching more than one
|
|
454
|
+
# control yields a RadioNodeList-like NodeList).
|
|
455
|
+
def named_controls
|
|
456
|
+
map = ::Hash.new { |h, k| h[k] = [] }
|
|
457
|
+
elements.each do |el|
|
|
458
|
+
next unless el.respond_to?(:__dommy_backend_node__)
|
|
459
|
+
|
|
460
|
+
node = el.__dommy_backend_node__
|
|
461
|
+
name = node["name"].to_s
|
|
462
|
+
map[name] << el unless name.empty?
|
|
463
|
+
id = node["id"].to_s
|
|
464
|
+
map[id] << el unless id.empty? || id == name
|
|
465
|
+
end
|
|
466
|
+
map
|
|
467
|
+
end
|
|
468
|
+
|
|
213
469
|
js_methods %w[submit reset requestSubmit checkValidity reportValidity]
|
|
214
470
|
def __js_call__(method, args)
|
|
215
471
|
case method
|
|
@@ -231,6 +487,7 @@ module Dommy
|
|
|
231
487
|
|
|
232
488
|
# `<input>` — covers the most-used form control surface.
|
|
233
489
|
class HTMLInputElement < HTMLElement
|
|
490
|
+
include SubmitButtonActivation
|
|
234
491
|
reflect_string :name, :placeholder, :min, :max, :step, :pattern, :autocomplete, default_value: "value"
|
|
235
492
|
reflect_boolean :autofocus, :disabled, :required, :readonly, default_checked: "checked"
|
|
236
493
|
# Own __js_call__ methods, on top of Element's.
|
|
@@ -239,6 +496,8 @@ module Dommy
|
|
|
239
496
|
raw.empty? ? "text" : raw.downcase
|
|
240
497
|
end
|
|
241
498
|
|
|
499
|
+
def __submit_button__? = %w[submit image].include?(type) && !disabled
|
|
500
|
+
|
|
242
501
|
def type=(v)
|
|
243
502
|
set_reflected_string("type", v)
|
|
244
503
|
end
|
|
@@ -248,11 +507,21 @@ module Dommy
|
|
|
248
507
|
# separately thereafter — matching browser semantics where the
|
|
249
508
|
# `value` IDL attribute can drift from the `value` content attr.
|
|
250
509
|
def value
|
|
251
|
-
|
|
510
|
+
raw = @__value.nil? ? reflected_string("value") : @__value
|
|
511
|
+
# checkbox/radio use the "default/on" value mode: with no value content
|
|
512
|
+
# attribute (and no assigned value) the IDL value is "on".
|
|
513
|
+
return "on" if raw.to_s.empty? && !@__node__.key?("value") && %w[checkbox radio].include?(type)
|
|
514
|
+
|
|
515
|
+
sanitize_value(raw)
|
|
252
516
|
end
|
|
253
517
|
|
|
254
518
|
def value=(v)
|
|
255
519
|
raw = v.to_s
|
|
520
|
+
# WHATWG: a file input's value IDL setter throws unless set to the empty
|
|
521
|
+
# string (which clears the selection).
|
|
522
|
+
if type == "file" && !raw.empty?
|
|
523
|
+
raise DOMException::InvalidStateError, "a file input's value may only be set to the empty string"
|
|
524
|
+
end
|
|
256
525
|
@__raw_value = raw
|
|
257
526
|
@__value = raw
|
|
258
527
|
end
|
|
@@ -260,6 +529,9 @@ module Dommy
|
|
|
260
529
|
# `files` — for `<input type="file">`. Browsers populate this via
|
|
261
530
|
# user interaction; in tests, code uses `__driver_set_files__` to seed it.
|
|
262
531
|
def files
|
|
532
|
+
# `files` is null for every type other than file (WHATWG).
|
|
533
|
+
return nil unless type == "file"
|
|
534
|
+
|
|
263
535
|
@__files ||= FileList.new
|
|
264
536
|
end
|
|
265
537
|
|
|
@@ -269,21 +541,39 @@ module Dommy
|
|
|
269
541
|
@__files = files_input.is_a?(FileList) ? files_input : FileList.new(Array(files_input))
|
|
270
542
|
end
|
|
271
543
|
|
|
544
|
+
# maxLength / minLength reflect a "limited to only non-negative numbers"
|
|
545
|
+
# long: a missing / negative / non-numeric content attribute reads as -1.
|
|
546
|
+
def max_length = parse_non_negative_reflected("maxlength")
|
|
547
|
+
def min_length = parse_non_negative_reflected("minlength")
|
|
548
|
+
|
|
549
|
+
def max_length=(value)
|
|
550
|
+
set_non_negative_reflected("maxlength", value)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def min_length=(value)
|
|
554
|
+
set_non_negative_reflected("minlength", value)
|
|
555
|
+
end
|
|
556
|
+
|
|
272
557
|
# Spec: the "value sanitization algorithm" runs lazily on read.
|
|
273
558
|
# type=email/url trim leading/trailing ASCII whitespace; type=number
|
|
274
559
|
# rejects non-finite floats by returning "" (badInput stays true
|
|
275
560
|
# so validity surfaces the original raw value).
|
|
276
561
|
def sanitize_value(raw)
|
|
277
562
|
case type
|
|
563
|
+
# The one-line text types "strip newlines from the value" (WHATWG value
|
|
564
|
+
# sanitization) — a pasted multi-line string collapses to one line.
|
|
565
|
+
when "text", "search", "tel", "password"
|
|
566
|
+
strip_newlines(raw.to_s)
|
|
278
567
|
when "email"
|
|
568
|
+
stripped = strip_newlines(raw.to_s)
|
|
279
569
|
if @__node__.key?("multiple")
|
|
280
|
-
|
|
570
|
+
stripped.split(",").map(&:strip).join(",")
|
|
281
571
|
else
|
|
282
|
-
|
|
572
|
+
stripped.strip
|
|
283
573
|
end
|
|
284
|
-
|
|
285
574
|
when "url"
|
|
286
|
-
|
|
575
|
+
# Strip newlines, then leading/trailing whitespace.
|
|
576
|
+
strip_newlines(raw.to_s).strip
|
|
287
577
|
when "number", "range"
|
|
288
578
|
sanitize_number(raw)
|
|
289
579
|
when "color"
|
|
@@ -294,6 +584,10 @@ module Dommy
|
|
|
294
584
|
end
|
|
295
585
|
end
|
|
296
586
|
|
|
587
|
+
def strip_newlines(str)
|
|
588
|
+
str.gsub(/[\r\n]/, "")
|
|
589
|
+
end
|
|
590
|
+
|
|
297
591
|
def sanitize_number(raw)
|
|
298
592
|
s = raw.to_s
|
|
299
593
|
Float(s)
|
|
@@ -400,54 +694,489 @@ module Dommy
|
|
|
400
694
|
@document&.__internal_bump_style_generation__
|
|
401
695
|
end
|
|
402
696
|
|
|
403
|
-
#
|
|
404
|
-
# form
|
|
405
|
-
|
|
697
|
+
# Two controls share a form owner when both are formless, or both point at
|
|
698
|
+
# the same form element (compared by backend node identity).
|
|
699
|
+
def same_form_owner?(a, b)
|
|
700
|
+
return b.nil? if a.nil?
|
|
701
|
+
return false if b.nil?
|
|
702
|
+
|
|
703
|
+
a.__dommy_backend_node__.equal?(b.__dommy_backend_node__)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# The radio button group: radios in the SAME tree (root node — so an orphan
|
|
707
|
+
# subtree groups too) that share this element's non-empty name and form
|
|
708
|
+
# owner (two radios with no form owner still group, as long as they share a
|
|
709
|
+
# tree and name).
|
|
406
710
|
def radio_group_members
|
|
407
711
|
group_name = get_attribute("name").to_s
|
|
408
712
|
return [self] if group_name.empty?
|
|
409
713
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return [self] unless
|
|
714
|
+
owner = form_owner
|
|
715
|
+
root = get_root_node
|
|
716
|
+
return [self] unless root.respond_to?(:query_selector_all)
|
|
717
|
+
|
|
718
|
+
members = root.query_selector_all("input[type='radio']").to_a.select do |radio|
|
|
719
|
+
next false unless radio.respond_to?(:form_owner)
|
|
720
|
+
next false unless radio.get_attribute("name").to_s == group_name
|
|
413
721
|
|
|
414
|
-
|
|
415
|
-
radio.get_attribute("name").to_s == group_name && radio.form.equal?(owner_form)
|
|
722
|
+
same_form_owner?(owner, radio.form_owner)
|
|
416
723
|
end
|
|
724
|
+
# `query_selector_all` searches descendants, so a detached radio (whose
|
|
725
|
+
# root node is itself) isn't returned — a radio is always in its own group.
|
|
726
|
+
members.any? { |m| m.__dommy_backend_node__.equal?(__dommy_backend_node__) } ? members : members + [self]
|
|
417
727
|
end
|
|
418
728
|
|
|
419
729
|
def labels
|
|
420
|
-
|
|
730
|
+
# A hidden input is not a labelable element, so it has no labels list.
|
|
731
|
+
return nil if type == "hidden"
|
|
421
732
|
|
|
422
|
-
|
|
733
|
+
labels_node_list
|
|
423
734
|
end
|
|
424
735
|
|
|
425
|
-
#
|
|
736
|
+
# The form owner (WebIDL `input.form`): the form referenced by a `form=`
|
|
737
|
+
# content attribute (when it resolves to a form element), otherwise the
|
|
738
|
+
# nearest ancestor form.
|
|
426
739
|
def form
|
|
740
|
+
form_owner
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def form_owner
|
|
744
|
+
form_id = get_attribute("form").to_s
|
|
745
|
+
unless form_id.empty?
|
|
746
|
+
target = @document.get_element_by_id(form_id)
|
|
747
|
+
return (target && target.tag_name.to_s.casecmp?("form")) ? target : nil
|
|
748
|
+
end
|
|
749
|
+
|
|
427
750
|
closest("form")
|
|
428
751
|
end
|
|
429
752
|
|
|
430
|
-
#
|
|
753
|
+
# Only these input types expose a variable-length text selection; the rest
|
|
754
|
+
# return null for the selection attributes and throw on the setters/methods.
|
|
755
|
+
SELECTION_TYPES = %w[text search url tel password].freeze
|
|
756
|
+
|
|
757
|
+
def supports_selection?
|
|
758
|
+
SELECTION_TYPES.include?(type)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def selection_start
|
|
762
|
+
return nil unless supports_selection?
|
|
763
|
+
|
|
764
|
+
@__selection_start ||= value.to_s.length
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def selection_start=(v)
|
|
768
|
+
require_selection!
|
|
769
|
+
@__selection_start = clamp_selection_index(v)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def selection_end
|
|
773
|
+
return nil unless supports_selection?
|
|
774
|
+
|
|
775
|
+
@__selection_end ||= value.to_s.length
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def selection_end=(v)
|
|
779
|
+
require_selection!
|
|
780
|
+
@__selection_end = clamp_selection_index(v)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def selection_direction
|
|
784
|
+
return nil unless supports_selection?
|
|
785
|
+
|
|
786
|
+
@__selection_direction || "none"
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def selection_direction=(v)
|
|
790
|
+
require_selection!
|
|
791
|
+
@__selection_direction = normalize_selection_direction(v)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# `select()` selects the whole control on a text control; on any other type
|
|
795
|
+
# it is a silent no-op (it does NOT throw).
|
|
431
796
|
def select
|
|
797
|
+
return nil unless supports_selection?
|
|
798
|
+
|
|
799
|
+
@__selection_start = 0
|
|
800
|
+
@__selection_end = value.to_s.length
|
|
801
|
+
@__selection_direction = "none"
|
|
432
802
|
nil
|
|
433
803
|
end
|
|
434
804
|
|
|
435
|
-
def set_selection_range(
|
|
805
|
+
def set_selection_range(start, finish, direction = nil)
|
|
806
|
+
require_selection!
|
|
807
|
+
len = value.to_s.length
|
|
808
|
+
e = clamp_selection_index(finish, len)
|
|
809
|
+
s = [clamp_selection_index(start, len), e].min
|
|
810
|
+
@__selection_start = s
|
|
811
|
+
@__selection_end = e
|
|
812
|
+
@__selection_direction = normalize_selection_direction(direction)
|
|
436
813
|
nil
|
|
437
814
|
end
|
|
438
815
|
|
|
439
816
|
def set_range_text(_replacement, *_)
|
|
817
|
+
require_selection!
|
|
440
818
|
nil
|
|
441
819
|
end
|
|
442
820
|
|
|
443
|
-
|
|
444
|
-
|
|
821
|
+
# Raise on the selection setters/methods for a type that has no text
|
|
822
|
+
# selection (email, number, checkbox, …).
|
|
823
|
+
def require_selection!
|
|
824
|
+
return if supports_selection?
|
|
825
|
+
|
|
826
|
+
raise DOMException::InvalidStateError, "The input element's type ('#{type}') does not support selection."
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def clamp_selection_index(v, len = value.to_s.length)
|
|
830
|
+
n = v.to_i
|
|
831
|
+
n.negative? ? 0 : [n, len].min
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def normalize_selection_direction(v)
|
|
835
|
+
d = v.to_s
|
|
836
|
+
%w[forward backward].include?(d) ? d : "none"
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Input types whose value has a numeric representation (valueAsNumber /
|
|
840
|
+
# stepUp / stepDown apply).
|
|
841
|
+
def numeric_value_type?
|
|
842
|
+
%w[number range date month week time datetime-local].include?(type)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
def range_min
|
|
846
|
+
Float(@__node__["min"].to_s) rescue 0.0
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def range_max
|
|
850
|
+
Float(@__node__["max"].to_s) rescue 100.0
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# A range with no (valid) value defaults to the midpoint of its range, or the
|
|
854
|
+
# minimum when the maximum is below it.
|
|
855
|
+
def default_range_value(lo, hi)
|
|
856
|
+
hi < lo ? lo : lo + (hi - lo) / 2.0
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# The declared step (default 1 for number, 1 for range); "any" disables
|
|
860
|
+
# stepping (returns nil).
|
|
861
|
+
def step_base_value
|
|
862
|
+
raw = @__node__["step"].to_s.strip
|
|
863
|
+
return nil if raw.casecmp?("any")
|
|
864
|
+
|
|
865
|
+
s = (Float(raw) rescue nil)
|
|
866
|
+
s && s > 0 ? s : default_step
|
|
445
867
|
end
|
|
446
868
|
|
|
447
|
-
|
|
869
|
+
# stepUp/stepDown throw when the type has no allowed value step: a type with
|
|
870
|
+
# no number representation, or step="any". Otherwise the value moves by
|
|
871
|
+
# `count` steps (in valueAsNumber units), clamped/aligned to the min & max.
|
|
872
|
+
def apply_step(count)
|
|
873
|
+
unless numeric_value_type?
|
|
874
|
+
raise DOMException::InvalidStateError, "stepUp/stepDown is not applicable to input type '#{type}'"
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
step = step_base_value
|
|
878
|
+
if step.nil?
|
|
879
|
+
raise DOMException::InvalidStateError, "stepUp/stepDown is not allowed when step is 'any'"
|
|
880
|
+
end
|
|
881
|
+
return if count.zero?
|
|
882
|
+
|
|
883
|
+
allowed = step * step_scale_factor
|
|
884
|
+
mn = step_boundary("min")
|
|
885
|
+
mx = step_boundary("max")
|
|
886
|
+
# A min above the max means no in-range value exists — do nothing.
|
|
887
|
+
return if mn && mx && mn > mx
|
|
888
|
+
|
|
889
|
+
before = value_as_number
|
|
890
|
+
base = before.nan? ? (mn || 0.0) : before
|
|
891
|
+
result = base + count * allowed
|
|
892
|
+
|
|
893
|
+
step_base = mn || 0.0
|
|
894
|
+
result = mx - (mx - step_base) % allowed if mx && result > mx
|
|
895
|
+
result = mn + (step_base - mn) % allowed if mn && result < mn
|
|
896
|
+
|
|
897
|
+
# Clamping must never move the value against the step direction (e.g. a
|
|
898
|
+
# stepDown on a value already below min must not jump UP to min).
|
|
899
|
+
unless before.nan?
|
|
900
|
+
return if count.positive? && result < before
|
|
901
|
+
return if count.negative? && result > before
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
self.value_as_number = result
|
|
448
905
|
nil
|
|
449
906
|
end
|
|
450
907
|
|
|
908
|
+
# The scale that turns one declared step into valueAsNumber units (ms for the
|
|
909
|
+
# date/time types, natural units for number/range/month).
|
|
910
|
+
def step_scale_factor
|
|
911
|
+
case type
|
|
912
|
+
when "date" then 86_400_000
|
|
913
|
+
when "week" then 604_800_000
|
|
914
|
+
when "time", "datetime-local" then 1000
|
|
915
|
+
else 1
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# The default allowed step (in the type's own step units) when `step` is
|
|
920
|
+
# absent or invalid: 60 (seconds) for time/datetime-local, 1 otherwise.
|
|
921
|
+
def default_step
|
|
922
|
+
%w[time datetime-local].include?(type) ? 60.0 : 1.0
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# The `min`/`max` boundary as a valueAsNumber, or nil when absent/unparseable.
|
|
926
|
+
def step_boundary(attr)
|
|
927
|
+
raw = @__node__[attr].to_s.strip
|
|
928
|
+
return nil if raw.empty?
|
|
929
|
+
|
|
930
|
+
case type
|
|
931
|
+
when "number", "range" then (Float(raw) rescue nil)
|
|
932
|
+
when "date" then nan_to_nil(date_string_to_ms(raw))
|
|
933
|
+
when "time" then nan_to_nil(time_string_to_ms(raw))
|
|
934
|
+
when "datetime-local" then nan_to_nil(datetime_local_string_to_ms(raw))
|
|
935
|
+
when "month" then nan_to_nil(month_string_to_number(raw))
|
|
936
|
+
when "week" then nan_to_nil(week_string_to_ms(raw))
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def nan_to_nil(n)
|
|
941
|
+
n.nan? ? nil : n
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# JS Number-to-string: an integral value drops the trailing ".0".
|
|
945
|
+
def number_to_string(n)
|
|
946
|
+
return "" if n.nan?
|
|
947
|
+
|
|
948
|
+
n == n.to_i ? n.to_i.to_s : n.to_s
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# WHATWG "valid floating-point number": no surrounding whitespace (unlike
|
|
952
|
+
# Ruby's Float()), optional sign, digits with optional fraction, optional
|
|
953
|
+
# exponent. Anything else — including " 1 " or "1e" — yields NaN.
|
|
954
|
+
VALID_FLOAT_RE = /\A-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?\z/
|
|
955
|
+
|
|
956
|
+
def parse_valid_float(str)
|
|
957
|
+
s = str.to_s
|
|
958
|
+
VALID_FLOAT_RE.match?(s) ? (Float(s) rescue ::Float::NAN) : ::Float::NAN
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# --- Date/time "convert a string to a number" algorithms (all UTC) --------
|
|
962
|
+
|
|
963
|
+
def date_string_to_ms(s)
|
|
964
|
+
m = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(s.strip)
|
|
965
|
+
return ::Float::NAN unless m
|
|
966
|
+
|
|
967
|
+
y, mo, d = m[1].to_i, m[2].to_i, m[3].to_i
|
|
968
|
+
return ::Float::NAN if y < 1 || !::Date.valid_date?(y, mo, d)
|
|
969
|
+
|
|
970
|
+
::Time.utc(y, mo, d).to_i * 1000.0
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def time_string_to_ms(s)
|
|
974
|
+
m = /\A(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?\z/.match(s.strip)
|
|
975
|
+
return ::Float::NAN unless m
|
|
976
|
+
|
|
977
|
+
h, mi, se = m[1].to_i, m[2].to_i, m[3].to_i
|
|
978
|
+
return ::Float::NAN if h > 23 || mi > 59 || se > 59
|
|
979
|
+
|
|
980
|
+
frac = m[4] ? m[4].ljust(3, "0").to_i : 0
|
|
981
|
+
((h * 3600 + mi * 60 + se) * 1000 + frac).to_f
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def datetime_local_string_to_ms(s)
|
|
985
|
+
# The date/time separator may be "T" or a space (the "parse a local date
|
|
986
|
+
# and time string" algorithm accepts both).
|
|
987
|
+
m = /\A(\d{4,})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?\z/.match(s.strip)
|
|
988
|
+
return ::Float::NAN unless m
|
|
989
|
+
|
|
990
|
+
y, mo, d, h, mi, se = m[1].to_i, m[2].to_i, m[3].to_i, m[4].to_i, m[5].to_i, m[6].to_i
|
|
991
|
+
return ::Float::NAN if y < 1 || !::Date.valid_date?(y, mo, d) || h > 23 || mi > 59 || se > 59
|
|
992
|
+
|
|
993
|
+
frac = m[7] ? m[7].ljust(3, "0").to_i : 0
|
|
994
|
+
(::Time.utc(y, mo, d, h, mi, se).to_i * 1000 + frac).to_f
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def month_string_to_number(s)
|
|
998
|
+
m = /\A(\d{4,})-(\d{2})\z/.match(s.strip)
|
|
999
|
+
return ::Float::NAN unless m
|
|
1000
|
+
|
|
1001
|
+
y, mo = m[1].to_i, m[2].to_i
|
|
1002
|
+
return ::Float::NAN if y < 1 || mo < 1 || mo > 12
|
|
1003
|
+
|
|
1004
|
+
((y - 1970) * 12 + (mo - 1)).to_f
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
def week_string_to_ms(s)
|
|
1008
|
+
m = /\A(\d{4,})-W(\d{2})\z/.match(s.strip)
|
|
1009
|
+
return ::Float::NAN unless m
|
|
1010
|
+
|
|
1011
|
+
y, w = m[1].to_i, m[2].to_i
|
|
1012
|
+
return ::Float::NAN if y < 1 || w < 1
|
|
1013
|
+
|
|
1014
|
+
# Date.commercial raises for a week beyond the ISO year's 52/53 weeks.
|
|
1015
|
+
d = ::Date.commercial(y, w, 1)
|
|
1016
|
+
::Time.utc(d.year, d.month, d.day).to_i * 1000.0
|
|
1017
|
+
rescue ::ArgumentError
|
|
1018
|
+
::Float::NAN
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
# --- Inverse: "convert a number to a string" for the date/time types -------
|
|
1022
|
+
|
|
1023
|
+
def utc_time_from_ms(ms)
|
|
1024
|
+
::Time.at(ms / 1000.0).utc
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def ms_to_date_string(ms)
|
|
1028
|
+
return "" if ms.nan?
|
|
1029
|
+
|
|
1030
|
+
t = utc_time_from_ms(ms)
|
|
1031
|
+
format("%04d-%02d-%02d", t.year, t.month, t.day)
|
|
1032
|
+
rescue ::RangeError, ::ArgumentError, ::FloatDomainError
|
|
1033
|
+
""
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
def ms_to_time_string(ms)
|
|
1037
|
+
return "" if ms.nan?
|
|
1038
|
+
|
|
1039
|
+
v = (ms % 86_400_000).to_i
|
|
1040
|
+
h = v / 3_600_000
|
|
1041
|
+
mi = (v % 3_600_000) / 60_000
|
|
1042
|
+
se = (v % 60_000) / 1000
|
|
1043
|
+
frac = v % 1000
|
|
1044
|
+
if se.zero? && frac.zero?
|
|
1045
|
+
format("%02d:%02d", h, mi)
|
|
1046
|
+
elsif frac.zero?
|
|
1047
|
+
format("%02d:%02d:%02d", h, mi, se)
|
|
1048
|
+
else
|
|
1049
|
+
format("%02d:%02d:%02d.%03d", h, mi, se, frac)
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def ms_to_datetime_local_string(ms)
|
|
1054
|
+
return "" if ms.nan?
|
|
1055
|
+
|
|
1056
|
+
t = utc_time_from_ms(ms)
|
|
1057
|
+
return "" if t.year < 1 || t.year > 9999
|
|
1058
|
+
|
|
1059
|
+
base = format("%04d-%02d-%02dT%02d:%02d", t.year, t.month, t.day, t.hour, t.min)
|
|
1060
|
+
frac = (ms % 1000).to_i
|
|
1061
|
+
if t.sec.zero? && frac.zero?
|
|
1062
|
+
base
|
|
1063
|
+
elsif frac.zero?
|
|
1064
|
+
base + format(":%02d", t.sec)
|
|
1065
|
+
else
|
|
1066
|
+
base + format(":%02d.%03d", t.sec, frac)
|
|
1067
|
+
end
|
|
1068
|
+
rescue ::RangeError, ::ArgumentError, ::FloatDomainError
|
|
1069
|
+
""
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
def number_to_month_string(n)
|
|
1073
|
+
return "" if n.nan?
|
|
1074
|
+
|
|
1075
|
+
months = n.to_i
|
|
1076
|
+
y = 1970 + (months.fdiv(12).floor)
|
|
1077
|
+
mo = months % 12
|
|
1078
|
+
format("%04d-%02d", y, mo + 1)
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
def ms_to_week_string(ms)
|
|
1082
|
+
return "" if ms.nan?
|
|
1083
|
+
|
|
1084
|
+
t = utc_time_from_ms(ms)
|
|
1085
|
+
d = ::Date.new(t.year, t.month, t.day)
|
|
1086
|
+
format("%04d-W%02d", d.cwyear, d.cweek)
|
|
1087
|
+
rescue ::RangeError, ::ArgumentError, ::FloatDomainError
|
|
1088
|
+
""
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
# `valueAsNumber` — the control's value as a number, per the type's
|
|
1092
|
+
# "convert a string to a number" algorithm (NaN when the type has no number
|
|
1093
|
+
# representation or the value doesn't parse). number/range are plain floats;
|
|
1094
|
+
# range additionally defaults to its midpoint and clamps to [min, max].
|
|
1095
|
+
def value_as_number
|
|
1096
|
+
case type
|
|
1097
|
+
when "number"
|
|
1098
|
+
parse_valid_float(value.to_s)
|
|
1099
|
+
when "range"
|
|
1100
|
+
n = parse_valid_float(value.to_s)
|
|
1101
|
+
n = nil if n.nan?
|
|
1102
|
+
lo = range_min
|
|
1103
|
+
hi = range_max
|
|
1104
|
+
n = default_range_value(lo, hi) if n.nil?
|
|
1105
|
+
n.clamp(lo, hi)
|
|
1106
|
+
when "date"
|
|
1107
|
+
date_string_to_ms(value.to_s)
|
|
1108
|
+
when "time"
|
|
1109
|
+
time_string_to_ms(value.to_s)
|
|
1110
|
+
when "datetime-local"
|
|
1111
|
+
datetime_local_string_to_ms(value.to_s)
|
|
1112
|
+
when "month"
|
|
1113
|
+
month_string_to_number(value.to_s)
|
|
1114
|
+
when "week"
|
|
1115
|
+
week_string_to_ms(value.to_s)
|
|
1116
|
+
else
|
|
1117
|
+
::Float::NAN
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
def value_as_number=(n)
|
|
1122
|
+
f = n.to_f
|
|
1123
|
+
case type
|
|
1124
|
+
when "number", "range"
|
|
1125
|
+
self.value = f.nan? ? "" : number_to_string(f)
|
|
1126
|
+
when "date"
|
|
1127
|
+
self.value = ms_to_date_string(f)
|
|
1128
|
+
when "time"
|
|
1129
|
+
self.value = ms_to_time_string(f)
|
|
1130
|
+
when "datetime-local"
|
|
1131
|
+
self.value = ms_to_datetime_local_string(f)
|
|
1132
|
+
when "month"
|
|
1133
|
+
self.value = number_to_month_string(f)
|
|
1134
|
+
when "week"
|
|
1135
|
+
self.value = ms_to_week_string(f)
|
|
1136
|
+
else
|
|
1137
|
+
raise DOMException::InvalidStateError, "valueAsNumber is not applicable to input type '#{type}'"
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Numeric-domain accessors shared with constraint validation (rangeUnderflow
|
|
1142
|
+
# / rangeOverflow / stepMismatch), all in valueAsNumber units.
|
|
1143
|
+
def min_as_number
|
|
1144
|
+
step_boundary("min")
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def max_as_number
|
|
1148
|
+
step_boundary("max")
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
# The allowed value step in valueAsNumber units, or nil for step="any" / a
|
|
1152
|
+
# type with no stepping.
|
|
1153
|
+
def allowed_value_step
|
|
1154
|
+
return nil unless numeric_value_type?
|
|
1155
|
+
|
|
1156
|
+
step = step_base_value
|
|
1157
|
+
step.nil? ? nil : step * step_scale_factor
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
# The step base for validation: the min boundary if present, else 0.
|
|
1161
|
+
def validation_step_base
|
|
1162
|
+
return min_as_number if min_as_number
|
|
1163
|
+
|
|
1164
|
+
# The default step base is 0 for most types, but a `week` control aligns to
|
|
1165
|
+
# the Monday of 1970-W01 (the epoch is mid-week), so an unmatched default
|
|
1166
|
+
# base would report every whole week as a step mismatch.
|
|
1167
|
+
type == "week" ? week_string_to_ms("1970-W01") : 0.0
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# `stepUp(n)` / `stepDown(n)` add/subtract n steps to the current number. The
|
|
1171
|
+
# WebIDL default for n is 1 (a missing/undefined arg crosses as nil).
|
|
1172
|
+
def step_up(n = 1)
|
|
1173
|
+
apply_step((n || 1).to_i)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
def step_down(n = 1)
|
|
1177
|
+
apply_step(-(n || 1).to_i)
|
|
1178
|
+
end
|
|
1179
|
+
|
|
451
1180
|
def validity
|
|
452
1181
|
@__validity ||= ValidityState.new(self)
|
|
453
1182
|
end
|
|
@@ -456,8 +1185,11 @@ module Dommy
|
|
|
456
1185
|
# Disabled / hidden / button-type inputs return false.
|
|
457
1186
|
def will_validate
|
|
458
1187
|
return false if reflected_boolean("disabled")
|
|
1188
|
+
return false if disabled_by_ancestor_fieldset?
|
|
459
1189
|
return false if reflected_boolean("readonly")
|
|
460
1190
|
return false if %w[hidden button submit reset image].include?(type)
|
|
1191
|
+
# A control with a datalist ancestor is barred from constraint validation.
|
|
1192
|
+
return false unless closest("datalist").nil?
|
|
461
1193
|
|
|
462
1194
|
true
|
|
463
1195
|
end
|
|
@@ -514,23 +1246,81 @@ module Dommy
|
|
|
514
1246
|
validation_message
|
|
515
1247
|
when "files"
|
|
516
1248
|
files
|
|
1249
|
+
when "selectionStart"
|
|
1250
|
+
selection_start
|
|
1251
|
+
when "selectionEnd"
|
|
1252
|
+
selection_end
|
|
1253
|
+
when "selectionDirection"
|
|
1254
|
+
selection_direction
|
|
1255
|
+
when "valueAsNumber"
|
|
1256
|
+
value_as_number
|
|
1257
|
+
when "maxLength"
|
|
1258
|
+
max_length
|
|
1259
|
+
when "minLength"
|
|
1260
|
+
min_length
|
|
1261
|
+
when "list"
|
|
1262
|
+
list
|
|
517
1263
|
else
|
|
518
1264
|
super
|
|
519
1265
|
end
|
|
520
1266
|
end
|
|
521
1267
|
|
|
1268
|
+
# HTML "cloning steps" for input: the dirty value flag + value and the dirty
|
|
1269
|
+
# checkedness flag + checkedness (plus indeterminate) — the user-modified
|
|
1270
|
+
# state a clone must retain beyond the default* content attributes. Returns
|
|
1271
|
+
# nil when the control is still pristine, so the walk skips it.
|
|
1272
|
+
def __cloning_state__
|
|
1273
|
+
state = {}
|
|
1274
|
+
state[:value] = @__value unless @__value.nil?
|
|
1275
|
+
state[:raw_value] = @__raw_value unless @__raw_value.nil?
|
|
1276
|
+
state[:checked] = @__checked unless @__checked.nil?
|
|
1277
|
+
state[:indeterminate] = @__indeterminate unless @__indeterminate.nil?
|
|
1278
|
+
state.empty? ? nil : state
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
def __apply_cloning_state__(state)
|
|
1282
|
+
@__value = state[:value] if state.key?(:value)
|
|
1283
|
+
@__raw_value = state[:raw_value] if state.key?(:raw_value)
|
|
1284
|
+
@__checked = state[:checked] if state.key?(:checked)
|
|
1285
|
+
@__indeterminate = state[:indeterminate] if state.key?(:indeterminate)
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
# HTMLInputElement.list — the <datalist> referenced by the `list` content
|
|
1289
|
+
# attribute (resolved by id, first element in tree order). Null when there
|
|
1290
|
+
# is no `list` attribute, no element with that id, or the referenced element
|
|
1291
|
+
# is not a <datalist>.
|
|
1292
|
+
def list
|
|
1293
|
+
id = get_attribute("list")
|
|
1294
|
+
return nil if id.nil? || id.empty?
|
|
1295
|
+
|
|
1296
|
+
element = @document.get_element_by_id(id)
|
|
1297
|
+
element.is_a?(HTMLDataListElement) ? element : nil
|
|
1298
|
+
end
|
|
1299
|
+
|
|
522
1300
|
def __js_set__(key, value)
|
|
523
1301
|
case key
|
|
524
1302
|
when "type"
|
|
525
1303
|
set_reflected_string("type", value)
|
|
526
1304
|
when "value"
|
|
527
1305
|
self.value = value
|
|
1306
|
+
when "valueAsNumber"
|
|
1307
|
+
self.value_as_number = value
|
|
1308
|
+
when "selectionStart"
|
|
1309
|
+
self.selection_start = value
|
|
1310
|
+
when "selectionEnd"
|
|
1311
|
+
self.selection_end = value
|
|
1312
|
+
when "selectionDirection"
|
|
1313
|
+
self.selection_direction = value
|
|
528
1314
|
when "checked"
|
|
529
1315
|
self.checked = value
|
|
530
1316
|
when "indeterminate"
|
|
531
1317
|
self.indeterminate = value
|
|
532
1318
|
when "readonly", "readOnly"
|
|
533
1319
|
self.readonly = value
|
|
1320
|
+
when "maxLength"
|
|
1321
|
+
self.max_length = value
|
|
1322
|
+
when "minLength"
|
|
1323
|
+
self.min_length = value
|
|
534
1324
|
else
|
|
535
1325
|
super
|
|
536
1326
|
end
|
|
@@ -566,34 +1356,45 @@ module Dommy
|
|
|
566
1356
|
|
|
567
1357
|
# `<button>` — type defaults to "submit" per spec.
|
|
568
1358
|
class HTMLButtonElement < HTMLElement
|
|
1359
|
+
include SubmitButtonActivation
|
|
569
1360
|
reflect_string :name, form_action: "formaction", form_enctype: "formenctype", form_method: "formmethod", form_target: "formtarget"
|
|
570
|
-
reflect_boolean form_no_validate: "formnovalidate"
|
|
1361
|
+
reflect_boolean :disabled, :autofocus, form_no_validate: "formnovalidate"
|
|
571
1362
|
def type
|
|
572
1363
|
raw = @__node__["type"].to_s.downcase
|
|
573
1364
|
%w[submit reset button].include?(raw) ? raw : "submit"
|
|
574
1365
|
end
|
|
575
1366
|
|
|
1367
|
+
def __submit_button__? = type == "submit" && !disabled
|
|
1368
|
+
|
|
576
1369
|
def type=(v)
|
|
577
1370
|
set_reflected_string("type", v)
|
|
578
1371
|
end
|
|
579
1372
|
|
|
1373
|
+
# The form owner: a `form=` attribute pointing at a form (form-associated
|
|
1374
|
+
# element, so a button can live outside its form), else the nearest ancestor
|
|
1375
|
+
# form. Mirrors HTMLInputElement#form_owner.
|
|
580
1376
|
def form
|
|
1377
|
+
form_id = get_attribute("form").to_s
|
|
1378
|
+
unless form_id.empty?
|
|
1379
|
+
target = @document.get_element_by_id(form_id)
|
|
1380
|
+
return (target && target.tag_name.to_s.casecmp?("form")) ? target : nil
|
|
1381
|
+
end
|
|
1382
|
+
|
|
581
1383
|
closest("form")
|
|
582
1384
|
end
|
|
583
1385
|
|
|
584
1386
|
def labels
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
@document.query_selector_all("label[for='#{id}']")
|
|
1387
|
+
labels_node_list
|
|
588
1388
|
end
|
|
589
1389
|
|
|
590
1390
|
def validity
|
|
591
1391
|
@__validity ||= ValidityState.new(self)
|
|
592
1392
|
end
|
|
593
1393
|
|
|
594
|
-
#
|
|
1394
|
+
# Only a submit button is a candidate for constraint validation; reset /
|
|
1395
|
+
# button types are barred, as are disabled controls and datalist descendants.
|
|
595
1396
|
def will_validate
|
|
596
|
-
|
|
1397
|
+
type == "submit" && !disabled && !disabled_by_ancestor_fieldset? && closest("datalist").nil?
|
|
597
1398
|
end
|
|
598
1399
|
|
|
599
1400
|
def validation_message
|
|
@@ -640,6 +1441,20 @@ module Dommy
|
|
|
640
1441
|
super
|
|
641
1442
|
end
|
|
642
1443
|
end
|
|
1444
|
+
|
|
1445
|
+
js_methods %w[checkValidity reportValidity setCustomValidity]
|
|
1446
|
+
def __js_call__(method, args)
|
|
1447
|
+
case method
|
|
1448
|
+
when "checkValidity"
|
|
1449
|
+
check_validity
|
|
1450
|
+
when "reportValidity"
|
|
1451
|
+
report_validity
|
|
1452
|
+
when "setCustomValidity"
|
|
1453
|
+
set_custom_validity(args[0])
|
|
1454
|
+
else
|
|
1455
|
+
super
|
|
1456
|
+
end
|
|
1457
|
+
end
|
|
643
1458
|
end
|
|
644
1459
|
|
|
645
1460
|
# `<img>` — reflected URL/dimension attributes. Dommy has no real
|
|
@@ -898,7 +1713,8 @@ module Dommy
|
|
|
898
1713
|
]
|
|
899
1714
|
.freeze
|
|
900
1715
|
|
|
901
|
-
|
|
1716
|
+
# The exact WHATWG "valid email address" production.
|
|
1717
|
+
EMAIL_RE = %r{\A[a-zA-Z0-9.!\#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z}
|
|
902
1718
|
URL_SCHEMES = %w[http:// https:// ftp://].freeze
|
|
903
1719
|
|
|
904
1720
|
def initialize(host = nil)
|
|
@@ -907,14 +1723,53 @@ module Dommy
|
|
|
907
1723
|
|
|
908
1724
|
# ---- Computed flags ----
|
|
909
1725
|
|
|
1726
|
+
# A control that is disabled or readonly is barred from constraint
|
|
1727
|
+
# validation — none of the "suffering from" flags apply.
|
|
1728
|
+
def host_barred?
|
|
1729
|
+
return false unless @host
|
|
1730
|
+
|
|
1731
|
+
disabled = host_attr_present?("disabled")
|
|
1732
|
+
readonly = @host.respond_to?(:readonly) ? @host.readonly : host_attr_present?("readonly")
|
|
1733
|
+
disabled || readonly
|
|
1734
|
+
end
|
|
1735
|
+
|
|
910
1736
|
def value_missing
|
|
911
1737
|
return false unless @host && host_attr_present?("required")
|
|
912
1738
|
|
|
913
1739
|
case host_type
|
|
914
|
-
when "checkbox"
|
|
915
|
-
|
|
1740
|
+
when "checkbox"
|
|
1741
|
+
# The checkbox/radio "being missing" flag reflects checkedness even when
|
|
1742
|
+
# the control is barred (only willValidate gates participation).
|
|
1743
|
+
!host_checked?
|
|
1744
|
+
when "radio"
|
|
1745
|
+
# An unnamed radio is not part of a group and is never missing.
|
|
1746
|
+
return false if @host.respond_to?(:get_attribute) && @host.get_attribute("name").to_s.empty?
|
|
1747
|
+
|
|
1748
|
+
# A required radio is missing only when NO member of its group (same
|
|
1749
|
+
# name/form owner/tree) is checked — using runtime checkedness.
|
|
1750
|
+
if @host.respond_to?(:radio_group_members)
|
|
1751
|
+
@host.radio_group_members.none? { |r| r.respond_to?(:checked) ? r.checked : false }
|
|
1752
|
+
else
|
|
1753
|
+
!host_checked?
|
|
1754
|
+
end
|
|
1755
|
+
when "file"
|
|
1756
|
+
files = @host.respond_to?(:files) ? @host.files : nil
|
|
1757
|
+
files.nil? || files.length.zero?
|
|
1758
|
+
when "select-one", "select-multiple"
|
|
1759
|
+
# A required select is missing when its selected option has an empty
|
|
1760
|
+
# value (the placeholder label option); the flag isn't barred by disabled.
|
|
1761
|
+
@host.respond_to?(:value) && @host.value.to_s.empty?
|
|
916
1762
|
else
|
|
917
|
-
|
|
1763
|
+
# Text-like controls only "suffer from being missing" when mutable.
|
|
1764
|
+
return false if host_barred?
|
|
1765
|
+
|
|
1766
|
+
# A date/number type with an unparseable value has no value (its
|
|
1767
|
+
# sanitized value is empty), so it counts as missing.
|
|
1768
|
+
if @host.respond_to?(:numeric_value_type?) && @host.send(:numeric_value_type?)
|
|
1769
|
+
@host.value_as_number.nan?
|
|
1770
|
+
else
|
|
1771
|
+
host_value.to_s.empty?
|
|
1772
|
+
end
|
|
918
1773
|
end
|
|
919
1774
|
end
|
|
920
1775
|
|
|
@@ -926,7 +1781,12 @@ module Dommy
|
|
|
926
1781
|
|
|
927
1782
|
case host_type
|
|
928
1783
|
when "email"
|
|
929
|
-
|
|
1784
|
+
# A `multiple` email is a comma-separated list; every part must be valid.
|
|
1785
|
+
if host_attr_present?("multiple")
|
|
1786
|
+
v.split(",", -1).any? { |part| !part.strip.match?(EMAIL_RE) }
|
|
1787
|
+
else
|
|
1788
|
+
!v.match?(EMAIL_RE)
|
|
1789
|
+
end
|
|
930
1790
|
when "url"
|
|
931
1791
|
URL_SCHEMES.none? { |s| v.start_with?(s) }
|
|
932
1792
|
else
|
|
@@ -943,71 +1803,74 @@ module Dommy
|
|
|
943
1803
|
v = host_value.to_s
|
|
944
1804
|
return false if v.empty?
|
|
945
1805
|
|
|
1806
|
+
# The pattern must be a valid regex ON ITS OWN — validate it before
|
|
1807
|
+
# anchoring, so an unbalanced `a)(b` (which the `(?:…)` wrapper would
|
|
1808
|
+
# otherwise balance) is correctly discarded rather than silently matched.
|
|
1809
|
+
Regexp.new(pat)
|
|
946
1810
|
!Regexp.new("\\A(?:#{pat})\\z").match?(v)
|
|
947
1811
|
rescue RegexpError
|
|
948
1812
|
false
|
|
949
1813
|
end
|
|
950
1814
|
|
|
1815
|
+
# tooLong / tooShort apply ONLY when the value was last changed by a USER
|
|
1816
|
+
# EDIT (not a script assignment), per the WHATWG "suffering from being too
|
|
1817
|
+
# long/short" definitions. Dommy has no interactive text entry, so a value is
|
|
1818
|
+
# never user-edited and these constraints never fire.
|
|
951
1819
|
def too_long
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
max = host_attr_value("maxlength").to_s
|
|
955
|
-
return false if max.empty?
|
|
956
|
-
|
|
957
|
-
max_n = max.to_i
|
|
958
|
-
return false if max_n < 0
|
|
959
|
-
|
|
960
|
-
host_value.to_s.length > max_n
|
|
1820
|
+
false
|
|
961
1821
|
end
|
|
962
1822
|
|
|
963
1823
|
def too_short
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
min = host_attr_value("minlength").to_s
|
|
967
|
-
return false if min.empty?
|
|
968
|
-
|
|
969
|
-
min_n = min.to_i
|
|
970
|
-
return false if min_n < 0
|
|
971
|
-
|
|
972
|
-
v = host_value.to_s
|
|
973
|
-
!v.empty? && v.length < min_n
|
|
1824
|
+
false
|
|
974
1825
|
end
|
|
975
1826
|
|
|
976
1827
|
def range_underflow
|
|
977
1828
|
return false unless numeric_host?
|
|
978
1829
|
|
|
979
|
-
min =
|
|
980
|
-
return false if min.
|
|
1830
|
+
min = @host.min_as_number
|
|
1831
|
+
return false if min.nil?
|
|
1832
|
+
|
|
1833
|
+
num = @host.value_as_number
|
|
1834
|
+
return false if num.nan?
|
|
1835
|
+
# A `time` control has a periodic domain: min > max means a REVERSED range
|
|
1836
|
+
# whose accepted values are `>= min` OR `<= max`, so both underflow and
|
|
1837
|
+
# overflow hold for a value in the excluded gap (max, min).
|
|
1838
|
+
max = @host.max_as_number
|
|
1839
|
+
return num > max && num < min if reversed_range?(min, max)
|
|
981
1840
|
|
|
982
|
-
num
|
|
983
|
-
num && num < min.to_f
|
|
1841
|
+
num < min
|
|
984
1842
|
end
|
|
985
1843
|
|
|
986
1844
|
def range_overflow
|
|
987
1845
|
return false unless numeric_host?
|
|
988
1846
|
|
|
989
|
-
max =
|
|
990
|
-
return false if max.
|
|
1847
|
+
max = @host.max_as_number
|
|
1848
|
+
return false if max.nil?
|
|
1849
|
+
|
|
1850
|
+
num = @host.value_as_number
|
|
1851
|
+
return false if num.nan?
|
|
1852
|
+
min = @host.min_as_number
|
|
1853
|
+
return num > max && num < min if reversed_range?(min, max)
|
|
1854
|
+
|
|
1855
|
+
num > max
|
|
1856
|
+
end
|
|
991
1857
|
|
|
992
|
-
|
|
993
|
-
|
|
1858
|
+
# A reversed range only exists for the periodic `time` domain with min > max.
|
|
1859
|
+
def reversed_range?(min, max)
|
|
1860
|
+
host_type == "time" && min && max && min > max
|
|
994
1861
|
end
|
|
995
1862
|
|
|
996
1863
|
def step_mismatch
|
|
997
1864
|
return false unless numeric_host?
|
|
998
1865
|
|
|
999
|
-
step =
|
|
1000
|
-
return false if step.
|
|
1001
|
-
|
|
1002
|
-
step_n = step.to_f
|
|
1003
|
-
return false if step_n <= 0
|
|
1866
|
+
step = @host.allowed_value_step
|
|
1867
|
+
return false if step.nil?
|
|
1004
1868
|
|
|
1005
|
-
num =
|
|
1006
|
-
return false
|
|
1869
|
+
num = @host.value_as_number
|
|
1870
|
+
return false if num.nan?
|
|
1007
1871
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
((num - base_n) / step_n - ((num - base_n) / step_n).round).abs > 1e-9
|
|
1872
|
+
ratio = (num - @host.validation_step_base) / step
|
|
1873
|
+
(ratio - ratio.round).abs > 1e-7
|
|
1011
1874
|
end
|
|
1012
1875
|
|
|
1013
1876
|
# `badInput` flags input that the user agent couldn't convert to
|
|
@@ -1106,6 +1969,12 @@ module Dommy
|
|
|
1106
1969
|
@host.__dommy_backend_node__.key?(name.to_s)
|
|
1107
1970
|
end
|
|
1108
1971
|
|
|
1972
|
+
# Runtime checkedness of a checkbox/radio host (the `.checked` IDL state,
|
|
1973
|
+
# which can drift from the `checked` content attribute).
|
|
1974
|
+
def host_checked?
|
|
1975
|
+
@host.respond_to?(:checked) ? @host.checked : host_attr_present?("checked")
|
|
1976
|
+
end
|
|
1977
|
+
|
|
1109
1978
|
def host_type
|
|
1110
1979
|
return nil unless @host
|
|
1111
1980
|
|
|
@@ -1119,7 +1988,8 @@ module Dommy
|
|
|
1119
1988
|
end
|
|
1120
1989
|
|
|
1121
1990
|
def numeric_host?
|
|
1122
|
-
@host.is_a?(HTMLInputElement) &&
|
|
1991
|
+
@host.is_a?(HTMLInputElement) && @host.respond_to?(:numeric_value_type?) &&
|
|
1992
|
+
@host.send(:numeric_value_type?)
|
|
1123
1993
|
end
|
|
1124
1994
|
|
|
1125
1995
|
def numeric_value
|
|
@@ -1139,41 +2009,120 @@ module Dommy
|
|
|
1139
2009
|
|
|
1140
2010
|
# `<option>` — value, label, selected, disabled, text, index, form.
|
|
1141
2011
|
class HTMLOptionElement < HTMLElement
|
|
1142
|
-
reflect_boolean :
|
|
2012
|
+
reflect_boolean :disabled
|
|
1143
2013
|
def value
|
|
1144
|
-
#
|
|
1145
|
-
# attribute
|
|
1146
|
-
|
|
2014
|
+
# `value`/`label` reflect the NO-namespace content attribute (a same-named
|
|
2015
|
+
# attribute in another namespace, via setAttributeNS, does not count);
|
|
2016
|
+
# both default to the option's `text` when their attribute is absent.
|
|
2017
|
+
has_attribute_ns?(nil, "value") ? get_attribute_ns(nil, "value").to_s : text
|
|
2018
|
+
end
|
|
2019
|
+
|
|
2020
|
+
def value=(v)
|
|
2021
|
+
set_reflected_string("value", v)
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
def label
|
|
2025
|
+
has_attribute_ns?(nil, "label") ? get_attribute_ns(nil, "label").to_s : text
|
|
2026
|
+
end
|
|
2027
|
+
|
|
2028
|
+
def label=(v)
|
|
2029
|
+
set_reflected_string("label", v)
|
|
2030
|
+
end
|
|
2031
|
+
|
|
2032
|
+
# `defaultSelected` reflects the `selected` content attribute.
|
|
2033
|
+
def default_selected
|
|
2034
|
+
reflected_boolean("selected")
|
|
2035
|
+
end
|
|
2036
|
+
|
|
2037
|
+
def default_selected=(v)
|
|
2038
|
+
set_reflected_boolean("selected", v)
|
|
2039
|
+
end
|
|
2040
|
+
|
|
2041
|
+
# `selected` is the selectedness state. It is a distinct boolean (the IDL
|
|
2042
|
+
# getter returns it directly), initialised from defaultSelected. While the
|
|
2043
|
+
# dirtiness flag is false, adding/removing the `selected` content attribute
|
|
2044
|
+
# re-syncs selectedness to it; the IDL setter makes it dirty so it then holds
|
|
2045
|
+
# its value independently of the attribute.
|
|
2046
|
+
def selected
|
|
2047
|
+
@selectedness = default_selected if @selectedness.nil?
|
|
2048
|
+
@selectedness
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
def selected=(value)
|
|
2052
|
+
@selectedness = !!value
|
|
2053
|
+
@selectedness_dirty = true
|
|
1147
2054
|
end
|
|
1148
2055
|
|
|
1149
|
-
|
|
1150
|
-
|
|
2056
|
+
# Set selectedness WITHOUT marking it dirty (the Option constructor's step).
|
|
2057
|
+
def __internal_set_selectedness__(value)
|
|
2058
|
+
@selectedness = !!value
|
|
1151
2059
|
end
|
|
1152
2060
|
|
|
1153
|
-
|
|
1154
|
-
|
|
2061
|
+
# Whether selectedness was set via the IDL setter (property), as opposed to
|
|
2062
|
+
# only the content attribute — a single-select shows the most recently
|
|
2063
|
+
# property-selected option in preference to an attribute-selected one.
|
|
2064
|
+
def __selectedness_dirty__
|
|
2065
|
+
@selectedness_dirty || false
|
|
1155
2066
|
end
|
|
1156
2067
|
|
|
1157
|
-
|
|
1158
|
-
|
|
2068
|
+
# Keep the `selected` content attribute and selectedness in sync (while not
|
|
2069
|
+
# dirty) by hooking the attribute mutators, the way <details> tracks `open`.
|
|
2070
|
+
def set_attribute(name, value)
|
|
2071
|
+
result = super
|
|
2072
|
+
sync_selectedness_from_attribute if name.to_s.casecmp?("selected")
|
|
2073
|
+
result
|
|
1159
2074
|
end
|
|
1160
2075
|
|
|
1161
|
-
def
|
|
1162
|
-
|
|
2076
|
+
def remove_attribute(name)
|
|
2077
|
+
result = super
|
|
2078
|
+
sync_selectedness_from_attribute if name.to_s.casecmp?("selected")
|
|
2079
|
+
result
|
|
1163
2080
|
end
|
|
1164
2081
|
|
|
1165
|
-
def
|
|
1166
|
-
|
|
2082
|
+
def sync_selectedness_from_attribute
|
|
2083
|
+
@selectedness = default_selected unless @selectedness_dirty
|
|
1167
2084
|
end
|
|
1168
2085
|
|
|
1169
2086
|
def text
|
|
1170
|
-
|
|
2087
|
+
# WHATWG: strip-and-collapse ASCII whitespace over the concatenated Text
|
|
2088
|
+
# node descendants — excluding any inside a script (HTML or SVG) element.
|
|
2089
|
+
parts = []
|
|
2090
|
+
collect_option_text(@__node__, parts)
|
|
2091
|
+
parts.join.gsub(/[\t\n\f\r ]+/, " ").strip
|
|
1171
2092
|
end
|
|
1172
2093
|
|
|
1173
2094
|
def text=(v)
|
|
1174
2095
|
self.text_content = v
|
|
1175
2096
|
end
|
|
1176
2097
|
|
|
2098
|
+
private
|
|
2099
|
+
|
|
2100
|
+
def collect_option_text(node, parts)
|
|
2101
|
+
node.children.each do |child|
|
|
2102
|
+
if child.text?
|
|
2103
|
+
parts << child.content
|
|
2104
|
+
elsif child.element? && !excluded_from_option_text?(child)
|
|
2105
|
+
collect_option_text(child, parts)
|
|
2106
|
+
end
|
|
2107
|
+
end
|
|
2108
|
+
end
|
|
2109
|
+
|
|
2110
|
+
# Per spec, option.text skips the descendants of an HTML/SVG `script` and an
|
|
2111
|
+
# HTML `style` element — but NOT a same-named element in another namespace
|
|
2112
|
+
# (a MathML or null-namespace `<script>` still contributes its text).
|
|
2113
|
+
def excluded_from_option_text?(node)
|
|
2114
|
+
name = node.name.to_s.downcase
|
|
2115
|
+
return false unless %w[script style].include?(name)
|
|
2116
|
+
|
|
2117
|
+
el = @document.wrap_node(node)
|
|
2118
|
+
ns = el.respond_to?(:namespace_uri) ? el.namespace_uri : nil
|
|
2119
|
+
html = "http://www.w3.org/1999/xhtml"
|
|
2120
|
+
svg = "http://www.w3.org/2000/svg"
|
|
2121
|
+
name == "script" ? [html, svg].include?(ns) : ns == html
|
|
2122
|
+
end
|
|
2123
|
+
|
|
2124
|
+
public
|
|
2125
|
+
|
|
1177
2126
|
def form
|
|
1178
2127
|
closest("form")
|
|
1179
2128
|
end
|
|
@@ -1194,6 +2143,8 @@ module Dommy
|
|
|
1194
2143
|
label
|
|
1195
2144
|
when "defaultSelected"
|
|
1196
2145
|
default_selected
|
|
2146
|
+
when "selected"
|
|
2147
|
+
selected
|
|
1197
2148
|
when "text"
|
|
1198
2149
|
text
|
|
1199
2150
|
when "form"
|
|
@@ -1211,8 +2162,10 @@ module Dommy
|
|
|
1211
2162
|
self.value = v
|
|
1212
2163
|
when "label"
|
|
1213
2164
|
self.label = v
|
|
1214
|
-
when "selected"
|
|
2165
|
+
when "selected"
|
|
1215
2166
|
self.selected = v
|
|
2167
|
+
when "defaultSelected"
|
|
2168
|
+
self.default_selected = v
|
|
1216
2169
|
when "text"
|
|
1217
2170
|
self.text = v
|
|
1218
2171
|
else
|
|
@@ -1231,15 +2184,21 @@ module Dommy
|
|
|
1231
2184
|
class HTMLTextAreaElement < HTMLElement
|
|
1232
2185
|
reflect_string :name, :placeholder, :wrap, :autocomplete
|
|
1233
2186
|
# Own __js_call__ methods, on top of Element's.
|
|
2187
|
+
|
|
2188
|
+
# The API value is the "raw value" — the dirty value once set (a wrapper-level
|
|
2189
|
+
# flag, NOT a content attribute, so `setAttribute("value", …)` can't touch it),
|
|
2190
|
+
# otherwise the default value (the element's child text content).
|
|
1234
2191
|
def value
|
|
1235
|
-
@
|
|
2192
|
+
@__value_dirty ? @__value.to_s : default_value
|
|
1236
2193
|
end
|
|
1237
2194
|
|
|
1238
2195
|
def value=(v)
|
|
1239
|
-
@
|
|
1240
|
-
|
|
2196
|
+
@__value = v.to_s
|
|
2197
|
+
@__value_dirty = true
|
|
1241
2198
|
end
|
|
1242
2199
|
|
|
2200
|
+
# defaultValue is the child text content; setting it (or `text`) leaves the
|
|
2201
|
+
# dirty value flag alone.
|
|
1243
2202
|
def default_value
|
|
1244
2203
|
text_content
|
|
1245
2204
|
end
|
|
@@ -1248,6 +2207,19 @@ module Dommy
|
|
|
1248
2207
|
self.text_content = v
|
|
1249
2208
|
end
|
|
1250
2209
|
|
|
2210
|
+
# HTML cloning steps: copy the dirty value flag + raw value so a clone keeps
|
|
2211
|
+
# the user-entered text rather than reverting to the default (child text).
|
|
2212
|
+
def __cloning_state__
|
|
2213
|
+
@__value_dirty ? { value: @__value, dirty: true } : nil
|
|
2214
|
+
end
|
|
2215
|
+
|
|
2216
|
+
def __apply_cloning_state__(state)
|
|
2217
|
+
return unless state[:dirty]
|
|
2218
|
+
|
|
2219
|
+
@__value = state[:value]
|
|
2220
|
+
@__value_dirty = true
|
|
2221
|
+
end
|
|
2222
|
+
|
|
1251
2223
|
def rows
|
|
1252
2224
|
(@__node__["rows"] || "2").to_i
|
|
1253
2225
|
end
|
|
@@ -1264,14 +2236,28 @@ module Dommy
|
|
|
1264
2236
|
set_reflected_string("cols", v.to_s)
|
|
1265
2237
|
end
|
|
1266
2238
|
|
|
2239
|
+
# `maxLength` / `minLength` reflect a "limited to only non-negative numbers"
|
|
2240
|
+
# long: a missing / negative / non-numeric content attribute is -1.
|
|
1267
2241
|
def max_length
|
|
1268
|
-
(
|
|
2242
|
+
parse_non_negative_reflected("maxlength")
|
|
1269
2243
|
end
|
|
1270
2244
|
|
|
1271
2245
|
def min_length
|
|
1272
|
-
(
|
|
2246
|
+
parse_non_negative_reflected("minlength")
|
|
2247
|
+
end
|
|
2248
|
+
|
|
2249
|
+
def max_length=(value)
|
|
2250
|
+
set_non_negative_reflected("maxlength", value)
|
|
2251
|
+
end
|
|
2252
|
+
|
|
2253
|
+
def min_length=(value)
|
|
2254
|
+
set_non_negative_reflected("minlength", value)
|
|
1273
2255
|
end
|
|
1274
2256
|
|
|
2257
|
+
private
|
|
2258
|
+
|
|
2259
|
+
public
|
|
2260
|
+
|
|
1275
2261
|
def text_length
|
|
1276
2262
|
value.length
|
|
1277
2263
|
end
|
|
@@ -1285,9 +2271,7 @@ module Dommy
|
|
|
1285
2271
|
end
|
|
1286
2272
|
|
|
1287
2273
|
def labels
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
@document.query_selector_all("label[for='#{id}']")
|
|
2274
|
+
labels_node_list
|
|
1291
2275
|
end
|
|
1292
2276
|
|
|
1293
2277
|
# No real selection — same stub story as input.
|
|
@@ -1308,7 +2292,8 @@ module Dommy
|
|
|
1308
2292
|
end
|
|
1309
2293
|
|
|
1310
2294
|
def will_validate
|
|
1311
|
-
!reflected_boolean("disabled") && !
|
|
2295
|
+
!reflected_boolean("disabled") && !disabled_by_ancestor_fieldset? &&
|
|
2296
|
+
!reflected_boolean("readonly") && closest("datalist").nil?
|
|
1312
2297
|
end
|
|
1313
2298
|
|
|
1314
2299
|
def validation_message
|
|
@@ -1380,9 +2365,9 @@ module Dommy
|
|
|
1380
2365
|
when "cols"
|
|
1381
2366
|
self.cols = v
|
|
1382
2367
|
when "maxLength"
|
|
1383
|
-
|
|
2368
|
+
self.max_length = v
|
|
1384
2369
|
when "minLength"
|
|
1385
|
-
|
|
2370
|
+
self.min_length = v
|
|
1386
2371
|
else
|
|
1387
2372
|
super
|
|
1388
2373
|
end
|
|
@@ -1421,12 +2406,25 @@ module Dommy
|
|
|
1421
2406
|
def control
|
|
1422
2407
|
target = html_for
|
|
1423
2408
|
if !target.empty?
|
|
1424
|
-
@document.get_element_by_id(target)
|
|
2409
|
+
el = @document.get_element_by_id(target)
|
|
2410
|
+
el if el && labelable_control?(el)
|
|
1425
2411
|
else
|
|
1426
|
-
|
|
2412
|
+
# The first labelable descendant in tree order (a hidden input, being
|
|
2413
|
+
# non-labelable, is skipped).
|
|
2414
|
+
query_selector_all("button, input, meter, output, progress, select, textarea")
|
|
2415
|
+
.to_a.find { |c| labelable_control?(c) }
|
|
1427
2416
|
end
|
|
1428
2417
|
end
|
|
1429
2418
|
|
|
2419
|
+
# Labelable elements: button, input (except type=hidden), meter, output,
|
|
2420
|
+
# progress, select, textarea.
|
|
2421
|
+
def labelable_control?(el)
|
|
2422
|
+
tag = el.tag_name.to_s.downcase
|
|
2423
|
+
return el.type.to_s.downcase != "hidden" if tag == "input"
|
|
2424
|
+
|
|
2425
|
+
%w[button meter output progress select textarea].include?(tag)
|
|
2426
|
+
end
|
|
2427
|
+
|
|
1430
2428
|
def form
|
|
1431
2429
|
closest("form")
|
|
1432
2430
|
end
|
|
@@ -1445,7 +2443,7 @@ module Dommy
|
|
|
1445
2443
|
|
|
1446
2444
|
# `<fieldset>` — disabled-state-propagating wrapper; exposes
|
|
1447
2445
|
# `elements` collection like form.
|
|
1448
|
-
class
|
|
2446
|
+
class HTMLFieldSetElement < HTMLElement
|
|
1449
2447
|
reflect_string :name
|
|
1450
2448
|
reflect_boolean :disabled
|
|
1451
2449
|
def type
|
|
@@ -1473,6 +2471,13 @@ module Dommy
|
|
|
1473
2471
|
ValidityState.new
|
|
1474
2472
|
end
|
|
1475
2473
|
|
|
2474
|
+
# A fieldset is "barred from constraint validation": it never participates,
|
|
2475
|
+
# so willValidate is always false and checkValidity/reportValidity are no-ops
|
|
2476
|
+
# that report success.
|
|
2477
|
+
def will_validate
|
|
2478
|
+
false
|
|
2479
|
+
end
|
|
2480
|
+
|
|
1476
2481
|
def check_validity
|
|
1477
2482
|
true
|
|
1478
2483
|
end
|
|
@@ -1491,6 +2496,20 @@ module Dommy
|
|
|
1491
2496
|
elements
|
|
1492
2497
|
when "validity"
|
|
1493
2498
|
validity
|
|
2499
|
+
when "willValidate"
|
|
2500
|
+
will_validate
|
|
2501
|
+
else
|
|
2502
|
+
super
|
|
2503
|
+
end
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
js_methods %w[checkValidity reportValidity]
|
|
2507
|
+
def __js_call__(method, args)
|
|
2508
|
+
case method
|
|
2509
|
+
when "checkValidity"
|
|
2510
|
+
check_validity
|
|
2511
|
+
when "reportValidity"
|
|
2512
|
+
report_validity
|
|
1494
2513
|
else
|
|
1495
2514
|
super
|
|
1496
2515
|
end
|
|
@@ -1500,20 +2519,34 @@ module Dommy
|
|
|
1500
2519
|
# `<output>` — calculation result element.
|
|
1501
2520
|
class HTMLOutputElement < HTMLElement
|
|
1502
2521
|
reflect_string :name
|
|
2522
|
+
|
|
2523
|
+
# `value` is always the descendant text content. `defaultValue` tracks a
|
|
2524
|
+
# separate "default value override": while the value mode flag is "default"
|
|
2525
|
+
# the two coincide (setting either updates the text), but once `value=` flips
|
|
2526
|
+
# the flag to "value" they diverge — the override is frozen and further
|
|
2527
|
+
# `defaultValue=` no longer touches the text content.
|
|
1503
2528
|
def value
|
|
1504
2529
|
text_content
|
|
1505
2530
|
end
|
|
1506
2531
|
|
|
1507
2532
|
def value=(v)
|
|
1508
|
-
|
|
2533
|
+
if @__value_mode != :value
|
|
2534
|
+
@__default_override = text_content
|
|
2535
|
+
@__value_mode = :value
|
|
2536
|
+
end
|
|
2537
|
+
self.text_content = v.to_s
|
|
1509
2538
|
end
|
|
1510
2539
|
|
|
1511
2540
|
def default_value
|
|
1512
|
-
text_content
|
|
2541
|
+
@__value_mode == :value ? @__default_override.to_s : text_content
|
|
1513
2542
|
end
|
|
1514
2543
|
|
|
1515
2544
|
def default_value=(v)
|
|
1516
|
-
|
|
2545
|
+
if @__value_mode == :value
|
|
2546
|
+
@__default_override = v.to_s
|
|
2547
|
+
else
|
|
2548
|
+
self.text_content = v.to_s
|
|
2549
|
+
end
|
|
1517
2550
|
end
|
|
1518
2551
|
|
|
1519
2552
|
# `for` attribute is a space-separated list of IDs.
|
|
@@ -1526,17 +2559,26 @@ module Dommy
|
|
|
1526
2559
|
end
|
|
1527
2560
|
|
|
1528
2561
|
def labels
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
@document.query_selector_all("label[for='#{id}']")
|
|
2562
|
+
labels_node_list
|
|
1532
2563
|
end
|
|
1533
2564
|
|
|
1534
2565
|
def type
|
|
1535
2566
|
"output"
|
|
1536
2567
|
end
|
|
1537
2568
|
|
|
2569
|
+
# An output has a validity state (customError is settable) but is barred
|
|
2570
|
+
# from constraint validation: willValidate is false, validationMessage is
|
|
2571
|
+
# always empty, and check/reportValidity always succeed.
|
|
1538
2572
|
def validity
|
|
1539
|
-
ValidityState.new
|
|
2573
|
+
@__validity ||= ValidityState.new(self)
|
|
2574
|
+
end
|
|
2575
|
+
|
|
2576
|
+
def will_validate
|
|
2577
|
+
false
|
|
2578
|
+
end
|
|
2579
|
+
|
|
2580
|
+
def validation_message
|
|
2581
|
+
""
|
|
1540
2582
|
end
|
|
1541
2583
|
|
|
1542
2584
|
def check_validity
|
|
@@ -1547,6 +2589,11 @@ module Dommy
|
|
|
1547
2589
|
true
|
|
1548
2590
|
end
|
|
1549
2591
|
|
|
2592
|
+
def set_custom_validity(msg)
|
|
2593
|
+
@custom_validity_message = msg.to_s
|
|
2594
|
+
nil
|
|
2595
|
+
end
|
|
2596
|
+
|
|
1550
2597
|
def __js_get__(key)
|
|
1551
2598
|
case key
|
|
1552
2599
|
when "value"
|
|
@@ -1561,6 +2608,10 @@ module Dommy
|
|
|
1561
2608
|
labels
|
|
1562
2609
|
when "validity"
|
|
1563
2610
|
validity
|
|
2611
|
+
when "willValidate"
|
|
2612
|
+
will_validate
|
|
2613
|
+
when "validationMessage"
|
|
2614
|
+
validation_message
|
|
1564
2615
|
when "htmlFor"
|
|
1565
2616
|
# `output.htmlFor` is a DOMTokenList (unlike `label.htmlFor`, a string).
|
|
1566
2617
|
reflected_token_list("htmlFor", "for")
|
|
@@ -1581,6 +2632,20 @@ module Dommy
|
|
|
1581
2632
|
super
|
|
1582
2633
|
end
|
|
1583
2634
|
end
|
|
2635
|
+
|
|
2636
|
+
js_methods %w[checkValidity reportValidity setCustomValidity]
|
|
2637
|
+
def __js_call__(method, args)
|
|
2638
|
+
case method
|
|
2639
|
+
when "checkValidity"
|
|
2640
|
+
check_validity
|
|
2641
|
+
when "reportValidity"
|
|
2642
|
+
report_validity
|
|
2643
|
+
when "setCustomValidity"
|
|
2644
|
+
set_custom_validity(args[0])
|
|
2645
|
+
else
|
|
2646
|
+
super
|
|
2647
|
+
end
|
|
2648
|
+
end
|
|
1584
2649
|
end
|
|
1585
2650
|
|
|
1586
2651
|
# `<legend>` — primarily exposes its `form` back-ref.
|
|
@@ -1717,58 +2782,72 @@ module Dommy
|
|
|
1717
2782
|
# back to the first option for non-multiple selects.
|
|
1718
2783
|
def selected_options
|
|
1719
2784
|
el = self
|
|
1720
|
-
HTMLCollection.new
|
|
1721
|
-
opts = el.__dommy_backend_node__.css("option").map { |n| el.document.wrap_node(n) }.compact
|
|
1722
|
-
chosen = opts.select { |o| o.__dommy_backend_node__.key?("selected") }
|
|
1723
|
-
next chosen unless chosen.empty?
|
|
1724
|
-
next [] if el.multiple
|
|
1725
|
-
|
|
1726
|
-
opts.first ? [opts.first] : []
|
|
1727
|
-
end
|
|
2785
|
+
@selected_options ||= HTMLCollection.new { el.__display_selected__ }
|
|
1728
2786
|
end
|
|
1729
2787
|
|
|
1730
2788
|
def length
|
|
1731
2789
|
options.size
|
|
1732
2790
|
end
|
|
1733
2791
|
|
|
2792
|
+
# `select.length = n` resizes the options list (delegates to the collection):
|
|
2793
|
+
# shrinks by removing trailing options, grows by appending blank ones.
|
|
2794
|
+
def length=(n)
|
|
2795
|
+
options.length = n
|
|
2796
|
+
end
|
|
2797
|
+
|
|
2798
|
+
# `select.namedItem(name)` — the first option whose id or name matches.
|
|
2799
|
+
def named_item(name)
|
|
2800
|
+
options.named_item(name)
|
|
2801
|
+
end
|
|
2802
|
+
|
|
1734
2803
|
def form
|
|
1735
2804
|
closest("form")
|
|
1736
2805
|
end
|
|
1737
2806
|
|
|
1738
|
-
#
|
|
1739
|
-
#
|
|
2807
|
+
# The option(s) that display as selected, applying the selectedness rules at
|
|
2808
|
+
# read time: a single-select shows the LAST option whose selectedness is
|
|
2809
|
+
# true (last-selected wins), or — if none is — its first option ("ask for
|
|
2810
|
+
# reset"); a multiple select shows every selected option (or none).
|
|
2811
|
+
def __display_selected__
|
|
2812
|
+
opts = options.to_a
|
|
2813
|
+
chosen = opts.select { |o| o.respond_to?(:selected) && o.selected }
|
|
2814
|
+
if multiple
|
|
2815
|
+
chosen
|
|
2816
|
+
elsif !chosen.empty?
|
|
2817
|
+
# Single-select: the most recently property-selected option wins over an
|
|
2818
|
+
# attribute-selected one; otherwise the last selected in document order.
|
|
2819
|
+
dirty = chosen.select { |o| o.respond_to?(:__selectedness_dirty__) && o.__selectedness_dirty__ }
|
|
2820
|
+
[(dirty.empty? ? chosen : dirty).last]
|
|
2821
|
+
elsif !opts.empty?
|
|
2822
|
+
[opts.first]
|
|
2823
|
+
else
|
|
2824
|
+
[]
|
|
2825
|
+
end
|
|
2826
|
+
end
|
|
2827
|
+
|
|
1740
2828
|
def selected_index
|
|
1741
|
-
opts = options
|
|
1742
|
-
|
|
1743
|
-
return
|
|
2829
|
+
opts = options.to_a
|
|
2830
|
+
sel = __display_selected__.first
|
|
2831
|
+
return -1 unless sel
|
|
1744
2832
|
|
|
1745
|
-
|
|
2833
|
+
opts.find_index { |o| o.__dommy_backend_node__.equal?(sel.__dommy_backend_node__) } || -1
|
|
1746
2834
|
end
|
|
1747
2835
|
|
|
1748
2836
|
def selected_index=(i)
|
|
1749
|
-
|
|
1750
|
-
opts.each_with_index do |o, idx|
|
|
1751
|
-
if idx == i.to_i
|
|
1752
|
-
o.set_attribute("selected", "")
|
|
1753
|
-
elsif o.__dommy_backend_node__.key?("selected")
|
|
1754
|
-
o.remove_attribute("selected")
|
|
1755
|
-
end
|
|
1756
|
-
end
|
|
2837
|
+
options.to_a.each_with_index { |o, idx| o.selected = (idx == i.to_i) }
|
|
1757
2838
|
end
|
|
1758
2839
|
|
|
1759
|
-
# `value` of the select = value of the selected option, or "".
|
|
2840
|
+
# `value` of the select = value of the (displayed) selected option, or "".
|
|
1760
2841
|
def value
|
|
1761
|
-
|
|
1762
|
-
sel
|
|
1763
|
-
sel ? (sel.__dommy_backend_node__["value"] || sel.text_content).to_s : ""
|
|
2842
|
+
sel = __display_selected__.first
|
|
2843
|
+
sel ? sel.value.to_s : ""
|
|
1764
2844
|
end
|
|
1765
2845
|
|
|
1766
2846
|
def value=(new_value)
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
target.set_attribute("selected", "")
|
|
2847
|
+
opts = options.to_a
|
|
2848
|
+
target = opts.find { |o| o.value.to_s == new_value.to_s }
|
|
2849
|
+
opts.each { |o| o.selected = false }
|
|
2850
|
+
target.selected = true if target
|
|
1772
2851
|
end
|
|
1773
2852
|
|
|
1774
2853
|
# `select.item(i)` — returns the option at index i.
|
|
@@ -1793,8 +2872,12 @@ module Dommy
|
|
|
1793
2872
|
# inherits `remove()` from ChildNode for self-removal; spec lets
|
|
1794
2873
|
# both forms coexist via overloading.)
|
|
1795
2874
|
def remove_option(i)
|
|
1796
|
-
|
|
1797
|
-
|
|
2875
|
+
idx = i.to_i
|
|
2876
|
+
# An index out of range (including a negative one) is a no-op — NOT Ruby's
|
|
2877
|
+
# from-the-end negative indexing.
|
|
2878
|
+
return if idx.negative? || idx >= options.length
|
|
2879
|
+
|
|
2880
|
+
options[idx]&.remove
|
|
1798
2881
|
end
|
|
1799
2882
|
|
|
1800
2883
|
def labels
|
|
@@ -1812,7 +2895,7 @@ module Dommy
|
|
|
1812
2895
|
end
|
|
1813
2896
|
|
|
1814
2897
|
def will_validate
|
|
1815
|
-
!reflected_boolean("disabled")
|
|
2898
|
+
!reflected_boolean("disabled") && !disabled_by_ancestor_fieldset? && closest("datalist").nil?
|
|
1816
2899
|
end
|
|
1817
2900
|
|
|
1818
2901
|
def validation_message
|
|
@@ -1852,6 +2935,8 @@ module Dommy
|
|
|
1852
2935
|
size
|
|
1853
2936
|
when "selectedIndex"
|
|
1854
2937
|
selected_index
|
|
2938
|
+
when "selectedOptions"
|
|
2939
|
+
selected_options
|
|
1855
2940
|
when "form"
|
|
1856
2941
|
form
|
|
1857
2942
|
when "labels"
|
|
@@ -1865,6 +2950,10 @@ module Dommy
|
|
|
1865
2950
|
when "validationMessage"
|
|
1866
2951
|
validation_message
|
|
1867
2952
|
else
|
|
2953
|
+
# Indexed getter: `select[i]` is the option at index i (WebIDL).
|
|
2954
|
+
return item(key) if key.is_a?(Integer)
|
|
2955
|
+
return item(key.to_i) if key.is_a?(String) && key.match?(/\A\d+\z/)
|
|
2956
|
+
|
|
1868
2957
|
super
|
|
1869
2958
|
end
|
|
1870
2959
|
end
|
|
@@ -1875,18 +2964,30 @@ module Dommy
|
|
|
1875
2964
|
self.value = val
|
|
1876
2965
|
when "selectedIndex"
|
|
1877
2966
|
self.selected_index = val
|
|
2967
|
+
when "length"
|
|
2968
|
+
self.length = val
|
|
1878
2969
|
else
|
|
2970
|
+
# Indexed setter: `select[i] = option` delegates to the options
|
|
2971
|
+
# collection's WebIDL "set an indexed property" algorithm.
|
|
2972
|
+
return options.__set_indexed__(key.to_i, val) if key.is_a?(Integer) || (key.is_a?(String) && key.match?(/\A\d+\z/))
|
|
2973
|
+
|
|
1879
2974
|
super
|
|
1880
2975
|
end
|
|
1881
2976
|
end
|
|
1882
2977
|
|
|
1883
|
-
js_methods %w[item add checkValidity reportValidity setCustomValidity]
|
|
2978
|
+
js_methods %w[item namedItem add remove checkValidity reportValidity setCustomValidity]
|
|
1884
2979
|
def __js_call__(method, args)
|
|
1885
2980
|
case method
|
|
1886
2981
|
when "item"
|
|
1887
2982
|
item(args[0])
|
|
2983
|
+
when "namedItem"
|
|
2984
|
+
named_item(args[0])
|
|
1888
2985
|
when "add"
|
|
1889
2986
|
add(args[0], args[1])
|
|
2987
|
+
when "remove"
|
|
2988
|
+
# HTMLSelectElement.remove(index) removes an option; with no argument it
|
|
2989
|
+
# is ChildNode.remove() (removes the <select> itself).
|
|
2990
|
+
args.empty? ? super : remove_option(args[0])
|
|
1890
2991
|
when "checkValidity"
|
|
1891
2992
|
check_validity
|
|
1892
2993
|
when "reportValidity"
|
|
@@ -1919,15 +3020,34 @@ module Dommy
|
|
|
1919
3020
|
nil
|
|
1920
3021
|
end
|
|
1921
3022
|
|
|
3023
|
+
# `showModal()` requires the dialog to be connected and not already open;
|
|
3024
|
+
# otherwise it throws InvalidStateError. (Dommy has no top layer, so the
|
|
3025
|
+
# modal itself is functionally the same as show.)
|
|
1922
3026
|
def show_modal
|
|
3027
|
+
if has_attribute?("open")
|
|
3028
|
+
raise DOMException::InvalidStateError, "showModal() called on an open dialog"
|
|
3029
|
+
end
|
|
3030
|
+
unless is_connected?
|
|
3031
|
+
raise DOMException::InvalidStateError, "showModal() called on a dialog not connected to a document"
|
|
3032
|
+
end
|
|
3033
|
+
|
|
1923
3034
|
self.open = true
|
|
1924
3035
|
nil
|
|
1925
3036
|
end
|
|
1926
3037
|
|
|
3038
|
+
# `close(returnValue?)`: abort if the dialog isn't open; otherwise clear the
|
|
3039
|
+
# open attribute, optionally set returnValue, and QUEUE (async) a trusted,
|
|
3040
|
+
# non-bubbling `close` event.
|
|
1927
3041
|
def close(value = nil)
|
|
3042
|
+
return nil unless has_attribute?("open")
|
|
3043
|
+
|
|
1928
3044
|
self.open = false
|
|
1929
3045
|
@return_value = value.to_s unless value.nil?
|
|
1930
|
-
|
|
3046
|
+
fire = proc do
|
|
3047
|
+
dispatch_event(Event.new("close", "bubbles" => false, "cancelable" => false).__internal_mark_trusted__)
|
|
3048
|
+
end
|
|
3049
|
+
scheduler = @document.respond_to?(:default_view) && @document.default_view&.scheduler
|
|
3050
|
+
scheduler ? scheduler.set_timeout(fire, 0) : fire.call
|
|
1931
3051
|
nil
|
|
1932
3052
|
end
|
|
1933
3053
|
|
|
@@ -1971,6 +3091,8 @@ module Dommy
|
|
|
1971
3091
|
# `details.toggleAttribute("open")` fire toggle, which Stimulus's `:open`
|
|
1972
3092
|
# action option relies on.
|
|
1973
3093
|
class HTMLDetailsElement < HTMLElement
|
|
3094
|
+
reflect_string :name
|
|
3095
|
+
|
|
1974
3096
|
def open
|
|
1975
3097
|
reflected_boolean("open")
|
|
1976
3098
|
end
|
|
@@ -1980,7 +3102,11 @@ module Dommy
|
|
|
1980
3102
|
end
|
|
1981
3103
|
|
|
1982
3104
|
def set_attribute(name, value)
|
|
1983
|
-
with_toggle_on_open_change { super }
|
|
3105
|
+
result = with_toggle_on_open_change { super }
|
|
3106
|
+
# Re-point this element to a new exclusive group: if it is open, close the
|
|
3107
|
+
# other open members of the group it just joined.
|
|
3108
|
+
enforce_group_exclusivity if name.to_s.casecmp?("name") && open
|
|
3109
|
+
result
|
|
1984
3110
|
end
|
|
1985
3111
|
|
|
1986
3112
|
def remove_attribute(name)
|
|
@@ -2004,67 +3130,115 @@ module Dommy
|
|
|
2004
3130
|
def with_toggle_on_open_change
|
|
2005
3131
|
was = open
|
|
2006
3132
|
result = yield
|
|
2007
|
-
|
|
3133
|
+
if open != was
|
|
3134
|
+
# Opening a named details closes the other open members of its exclusive
|
|
3135
|
+
# group (same `name`, same tree scope) before its own toggle is queued.
|
|
3136
|
+
enforce_group_exclusivity if open
|
|
3137
|
+
queue_toggle_event(was, open)
|
|
3138
|
+
end
|
|
2008
3139
|
result
|
|
2009
3140
|
end
|
|
3141
|
+
|
|
3142
|
+
# WHATWG details name-group exclusivity: at most one details per (name, tree)
|
|
3143
|
+
# may be open. When this element opens, remove `open` from every other open
|
|
3144
|
+
# details in the same tree that shares its non-empty name.
|
|
3145
|
+
def enforce_group_exclusivity
|
|
3146
|
+
group = @__node__["name"].to_s
|
|
3147
|
+
return if group.empty?
|
|
3148
|
+
|
|
3149
|
+
root = get_root_node
|
|
3150
|
+
return unless root.respond_to?(:query_selector_all)
|
|
3151
|
+
|
|
3152
|
+
root.query_selector_all("details").each do |other|
|
|
3153
|
+
next unless other.respond_to?(:__dommy_backend_node__)
|
|
3154
|
+
next if other.__dommy_backend_node__.equal?(__dommy_backend_node__)
|
|
3155
|
+
next unless other.__dommy_backend_node__["name"].to_s == group
|
|
3156
|
+
|
|
3157
|
+
other.open = false if other.respond_to?(:open) && other.open
|
|
3158
|
+
end
|
|
3159
|
+
end
|
|
3160
|
+
|
|
3161
|
+
# WHATWG "queue a details toggle event task": the trusted ToggleEvent fires
|
|
3162
|
+
# asynchronously, and rapid changes coalesce into ONE event whose oldState is
|
|
3163
|
+
# the state before the first change and newState the state after the last.
|
|
3164
|
+
def queue_toggle_event(old_open, new_open)
|
|
3165
|
+
if @__toggle_pending
|
|
3166
|
+
@__toggle_new = new_open ? "open" : "closed"
|
|
3167
|
+
return
|
|
3168
|
+
end
|
|
3169
|
+
|
|
3170
|
+
@__toggle_pending = true
|
|
3171
|
+
@__toggle_old = old_open ? "open" : "closed"
|
|
3172
|
+
@__toggle_new = new_open ? "open" : "closed"
|
|
3173
|
+
fire = proc do
|
|
3174
|
+
@__toggle_pending = false
|
|
3175
|
+
evt = ToggleEvent.new("toggle",
|
|
3176
|
+
"oldState" => @__toggle_old, "newState" => @__toggle_new,
|
|
3177
|
+
"bubbles" => false, "cancelable" => false)
|
|
3178
|
+
dispatch_event(evt.__internal_mark_trusted__)
|
|
3179
|
+
end
|
|
3180
|
+
scheduler = @document.respond_to?(:default_view) && @document.default_view&.scheduler
|
|
3181
|
+
scheduler ? scheduler.set_timeout(fire, 0) : fire.call
|
|
3182
|
+
end
|
|
2010
3183
|
end
|
|
2011
3184
|
|
|
2012
3185
|
# `<meter>` — gauge with `value` / `min` / `max` (default 0/0/1)
|
|
2013
3186
|
# plus `low` / `high` / `optimum`. All numeric; `labels` via the
|
|
2014
3187
|
# standard `<label for="...">` association.
|
|
2015
3188
|
class HTMLMeterElement < HTMLElement
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
def value=(v)
|
|
2021
|
-
set_reflected_string("value", v.to_s)
|
|
2022
|
-
end
|
|
2023
|
-
|
|
3189
|
+
# The IDL getters return the WHATWG "actual" values, constrained in order:
|
|
3190
|
+
# min → max (≥min) → value (∈[min,max]) → low (∈[min,max]) →
|
|
3191
|
+
# high (∈[low,max]) → optimum (∈[min,max]).
|
|
2024
3192
|
def min
|
|
2025
3193
|
numeric_attr("min", 0.0)
|
|
2026
3194
|
end
|
|
2027
3195
|
|
|
2028
3196
|
def min=(v)
|
|
2029
|
-
set_reflected_string("min", v
|
|
3197
|
+
set_reflected_string("min", format_double(restricted_double(v)))
|
|
2030
3198
|
end
|
|
2031
3199
|
|
|
2032
3200
|
def max
|
|
2033
|
-
numeric_attr("max", 1.0)
|
|
3201
|
+
[numeric_attr("max", 1.0), min].max
|
|
2034
3202
|
end
|
|
2035
3203
|
|
|
2036
3204
|
def max=(v)
|
|
2037
|
-
set_reflected_string("max", v
|
|
3205
|
+
set_reflected_string("max", format_double(restricted_double(v)))
|
|
3206
|
+
end
|
|
3207
|
+
|
|
3208
|
+
def value
|
|
3209
|
+
clamp(numeric_attr("value", 0.0), min, max)
|
|
3210
|
+
end
|
|
3211
|
+
|
|
3212
|
+
def value=(v)
|
|
3213
|
+
set_reflected_string("value", format_double(restricted_double(v)))
|
|
2038
3214
|
end
|
|
2039
3215
|
|
|
2040
3216
|
def low
|
|
2041
|
-
numeric_attr("low", min)
|
|
3217
|
+
clamp(numeric_attr("low", min), min, max)
|
|
2042
3218
|
end
|
|
2043
3219
|
|
|
2044
3220
|
def low=(v)
|
|
2045
|
-
set_reflected_string("low", v
|
|
3221
|
+
set_reflected_string("low", format_double(restricted_double(v)))
|
|
2046
3222
|
end
|
|
2047
3223
|
|
|
2048
3224
|
def high
|
|
2049
|
-
numeric_attr("high", max)
|
|
3225
|
+
clamp(numeric_attr("high", max), low, max)
|
|
2050
3226
|
end
|
|
2051
3227
|
|
|
2052
3228
|
def high=(v)
|
|
2053
|
-
set_reflected_string("high", v
|
|
3229
|
+
set_reflected_string("high", format_double(restricted_double(v)))
|
|
2054
3230
|
end
|
|
2055
3231
|
|
|
2056
3232
|
def optimum
|
|
2057
|
-
numeric_attr("optimum", (min + max) / 2.0)
|
|
3233
|
+
clamp(numeric_attr("optimum", (min + max) / 2.0), min, max)
|
|
2058
3234
|
end
|
|
2059
3235
|
|
|
2060
3236
|
def optimum=(v)
|
|
2061
|
-
set_reflected_string("optimum", v
|
|
3237
|
+
set_reflected_string("optimum", format_double(restricted_double(v)))
|
|
2062
3238
|
end
|
|
2063
3239
|
|
|
2064
3240
|
def labels
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
@document.query_selector_all("label[for='#{id}']")
|
|
3241
|
+
labels_node_list
|
|
2068
3242
|
end
|
|
2069
3243
|
|
|
2070
3244
|
def __js_get__(key)
|
|
@@ -2090,10 +3264,13 @@ module Dommy
|
|
|
2090
3264
|
|
|
2091
3265
|
def __js_set__(key, v)
|
|
2092
3266
|
case key
|
|
2093
|
-
when "value"
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
3267
|
+
when "value" then self.value = v
|
|
3268
|
+
when "min" then self.min = v
|
|
3269
|
+
when "max" then self.max = v
|
|
3270
|
+
when "low" then self.low = v
|
|
3271
|
+
when "high" then self.high = v
|
|
3272
|
+
when "optimum" then self.optimum = v
|
|
3273
|
+
else super
|
|
2097
3274
|
end
|
|
2098
3275
|
end
|
|
2099
3276
|
|
|
@@ -2103,17 +3280,54 @@ module Dommy
|
|
|
2103
3280
|
raw = @__node__[name].to_s
|
|
2104
3281
|
raw.empty? ? default : Float(raw) rescue default
|
|
2105
3282
|
end
|
|
3283
|
+
|
|
3284
|
+
def clamp(v, lo, hi)
|
|
3285
|
+
return lo if v < lo
|
|
3286
|
+
return hi if v > hi
|
|
3287
|
+
|
|
3288
|
+
v
|
|
3289
|
+
end
|
|
3290
|
+
|
|
3291
|
+
# WebIDL `double` conversion (ToNumber) for the meter's IDL setters: a value
|
|
3292
|
+
# that coerces to NaN/±Infinity — e.g. `meter.value = "foobar"` — is a
|
|
3293
|
+
# restricted double and throws a TypeError.
|
|
3294
|
+
def restricted_double(v)
|
|
3295
|
+
n =
|
|
3296
|
+
case v
|
|
3297
|
+
when Numeric then v.to_f
|
|
3298
|
+
when nil then 0.0
|
|
3299
|
+
when true then 1.0
|
|
3300
|
+
when false then 0.0
|
|
3301
|
+
when String then (v.strip.empty? ? 0.0 : (Float(v.strip) rescue ::Float::NAN))
|
|
3302
|
+
else ::Float::NAN
|
|
3303
|
+
end
|
|
3304
|
+
raise Bridge::TypeError, "The provided double value is non-finite." if n.nan? || n.infinite?
|
|
3305
|
+
|
|
3306
|
+
n
|
|
3307
|
+
end
|
|
3308
|
+
|
|
3309
|
+
# The "best representation" of a double for a reflected content attribute:
|
|
3310
|
+
# an integral value loses its trailing ".0".
|
|
3311
|
+
def format_double(n)
|
|
3312
|
+
n == n.to_i ? n.to_i.to_s : n.to_s
|
|
3313
|
+
end
|
|
2106
3314
|
end
|
|
2107
3315
|
|
|
2108
3316
|
# `<progress>` — `value` and `max` (default max=1). `position`
|
|
2109
3317
|
# returns `value / max` for a "determinate" progress bar, or -1
|
|
2110
3318
|
# when no value is set ("indeterminate").
|
|
2111
3319
|
class HTMLProgressElement < HTMLElement
|
|
3320
|
+
# A progress bar is "determinate" iff it has a parseable `value` content
|
|
3321
|
+
# attribute; otherwise it is "indeterminate" (position -1). The `value` IDL
|
|
3322
|
+
# getter always returns a number: 0 when indeterminate/invalid, else the
|
|
3323
|
+
# value clamped to [0, max].
|
|
2112
3324
|
def value
|
|
2113
3325
|
raw = @__node__["value"].to_s
|
|
2114
|
-
raw.empty?
|
|
2115
|
-
|
|
2116
|
-
|
|
3326
|
+
return 0.0 if raw.empty?
|
|
3327
|
+
|
|
3328
|
+
v = Float(raw) rescue 0.0
|
|
3329
|
+
v = 0.0 if v < 0
|
|
3330
|
+
[v, max].min
|
|
2117
3331
|
end
|
|
2118
3332
|
|
|
2119
3333
|
def value=(v)
|
|
@@ -2122,27 +3336,28 @@ module Dommy
|
|
|
2122
3336
|
|
|
2123
3337
|
def max
|
|
2124
3338
|
raw = @__node__["max"].to_s
|
|
2125
|
-
raw.empty? ? 1.0 : (Float(raw) rescue 1.0)
|
|
3339
|
+
m = raw.empty? ? 1.0 : (Float(raw) rescue 1.0)
|
|
3340
|
+
# A `max` not greater than zero is invalid; the default (1) applies.
|
|
3341
|
+
m > 0 ? m : 1.0
|
|
2126
3342
|
end
|
|
2127
3343
|
|
|
3344
|
+
# The `max` IDL attribute is limited to numbers greater than zero: a setter
|
|
3345
|
+
# value that isn't is ignored (the content attribute is left unchanged).
|
|
2128
3346
|
def max=(v)
|
|
2129
|
-
|
|
3347
|
+
f = Float(v) rescue nil
|
|
3348
|
+
set_reflected_string("max", v.to_s) if f && f > 0
|
|
2130
3349
|
end
|
|
2131
3350
|
|
|
2132
|
-
# `position` = value/max for determinate
|
|
2133
|
-
#
|
|
3351
|
+
# `position` = value/max for a determinate bar; -1 for an indeterminate one
|
|
3352
|
+
# (no parseable value content attribute).
|
|
2134
3353
|
def position
|
|
2135
|
-
|
|
2136
|
-
return -1.0 if v.nil?
|
|
3354
|
+
return -1.0 unless determinate?
|
|
2137
3355
|
|
|
2138
|
-
|
|
2139
|
-
m <= 0 ? 1.0 : (v / m)
|
|
3356
|
+
value / max
|
|
2140
3357
|
end
|
|
2141
3358
|
|
|
2142
3359
|
def labels
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
@document.query_selector_all("label[for='#{id}']")
|
|
3360
|
+
labels_node_list
|
|
2146
3361
|
end
|
|
2147
3362
|
|
|
2148
3363
|
def __js_get__(key)
|
|
@@ -2162,12 +3377,24 @@ module Dommy
|
|
|
2162
3377
|
|
|
2163
3378
|
def __js_set__(key, v)
|
|
2164
3379
|
case key
|
|
2165
|
-
when "value"
|
|
2166
|
-
|
|
3380
|
+
when "value"
|
|
3381
|
+
self.value = v
|
|
3382
|
+
when "max"
|
|
3383
|
+
self.max = v
|
|
2167
3384
|
else
|
|
2168
3385
|
super
|
|
2169
3386
|
end
|
|
2170
3387
|
end
|
|
3388
|
+
|
|
3389
|
+
private
|
|
3390
|
+
|
|
3391
|
+
# Determinate iff the `value` content attribute is present and parseable.
|
|
3392
|
+
def determinate?
|
|
3393
|
+
raw = @__node__["value"].to_s
|
|
3394
|
+
return false if raw.empty?
|
|
3395
|
+
|
|
3396
|
+
!!(Float(raw) rescue nil)
|
|
3397
|
+
end
|
|
2171
3398
|
end
|
|
2172
3399
|
|
|
2173
3400
|
# `<template>` — `content` returns the DocumentFragment that
|
|
@@ -2194,8 +3421,10 @@ module Dommy
|
|
|
2194
3421
|
class HTMLTableCellElement < HTMLElement
|
|
2195
3422
|
reflect_string :headers, :scope, :abbr
|
|
2196
3423
|
def cell_index
|
|
2197
|
-
row
|
|
2198
|
-
|
|
3424
|
+
# cellIndex is the position in the DIRECT parent row's cells — -1 unless the
|
|
3425
|
+
# cell's immediate parent is a tr (a cell nested in a non-tr is not indexed).
|
|
3426
|
+
row = parent_element
|
|
3427
|
+
return -1 unless row.is_a?(HTMLTableRowElement)
|
|
2199
3428
|
|
|
2200
3429
|
row.cells.find_index { |c| c.__dommy_backend_node__ == @__node__ } || -1
|
|
2201
3430
|
end
|
|
@@ -2248,53 +3477,69 @@ module Dommy
|
|
|
2248
3477
|
# `rowIndex` walks the enclosing table; `sectionRowIndex` walks
|
|
2249
3478
|
# the enclosing thead/tbody/tfoot.
|
|
2250
3479
|
class HTMLTableRowElement < HTMLElement
|
|
3480
|
+
HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
|
|
3481
|
+
|
|
2251
3482
|
# Own __js_call__ methods, on top of Element's.
|
|
2252
3483
|
def cells
|
|
2253
3484
|
el = self
|
|
2254
3485
|
HTMLCollection.new do
|
|
2255
|
-
el
|
|
2256
|
-
.
|
|
2257
|
-
.
|
|
2258
|
-
.select { |n| %w[td th].include?(n.name) }
|
|
2259
|
-
.map { |n| el.document.wrap_node(n) }
|
|
2260
|
-
.compact
|
|
3486
|
+
el.__dommy_backend_node__.element_children
|
|
3487
|
+
.select { |n| %w[td th].include?(n.name) && el.__html_ns_node__(n) }
|
|
3488
|
+
.map { |n| el.document.wrap_node(n) }.compact
|
|
2261
3489
|
end
|
|
2262
3490
|
end
|
|
2263
3491
|
|
|
2264
3492
|
def row_index
|
|
2265
3493
|
table = closest("table")
|
|
2266
|
-
|
|
3494
|
+
# Only an HTML <table> exposes a rows collection; a foreign (namespaced)
|
|
3495
|
+
# <table> ancestor doesn't make this row a table row.
|
|
3496
|
+
return -1 unless table.is_a?(HTMLTableElement)
|
|
2267
3497
|
|
|
2268
3498
|
table.rows.find_index { |r| r.__dommy_backend_node__ == @__node__ } || -1
|
|
2269
3499
|
end
|
|
2270
3500
|
|
|
2271
3501
|
def section_row_index
|
|
2272
|
-
|
|
2273
|
-
return -1 unless
|
|
3502
|
+
parent = @__node__.parent
|
|
3503
|
+
return -1 unless parent && parent.element? && __html_ns_node__(parent) &&
|
|
3504
|
+
%w[table thead tbody tfoot].include?(parent.name)
|
|
2274
3505
|
|
|
2275
|
-
|
|
3506
|
+
parent.element_children
|
|
3507
|
+
.select { |n| n.name == "tr" && __html_ns_node__(n) }
|
|
3508
|
+
.find_index { |n| n == @__node__ } || -1
|
|
2276
3509
|
end
|
|
2277
3510
|
|
|
2278
|
-
# `insertCell(index)` — adds a `<td>` at the given index
|
|
2279
|
-
#
|
|
3511
|
+
# `insertCell(index)` — adds a `<td>` at the given index (defaults to end).
|
|
3512
|
+
# index < −1 or > cells.length throws IndexSizeError. Returns the new cell.
|
|
2280
3513
|
def insert_cell(index = -1)
|
|
3514
|
+
list = cells.to_a
|
|
3515
|
+
i = index.nil? ? -1 : index.to_i
|
|
3516
|
+
raise DOMException::IndexSizeError, "insertCell index #{i} out of range" if i < -1 || i > list.size
|
|
3517
|
+
|
|
2281
3518
|
cell = @document.create_element("td")
|
|
2282
|
-
|
|
2283
|
-
if index.to_i == -1 || index.to_i >= list.size
|
|
3519
|
+
if i == -1 || i == list.size
|
|
2284
3520
|
append_child(cell)
|
|
2285
3521
|
else
|
|
2286
|
-
insert_before(cell, list[
|
|
3522
|
+
insert_before(cell, list[i])
|
|
2287
3523
|
end
|
|
2288
3524
|
|
|
2289
3525
|
cell
|
|
2290
3526
|
end
|
|
2291
3527
|
|
|
2292
3528
|
def delete_cell(index)
|
|
2293
|
-
|
|
3529
|
+
list = cells.to_a
|
|
3530
|
+
i = index.to_i
|
|
3531
|
+
raise DOMException::IndexSizeError, "deleteCell index #{i} out of range" if i < -1 || i >= list.size
|
|
3532
|
+
|
|
3533
|
+
target = i == -1 ? list.last : list[i]
|
|
2294
3534
|
target&.remove
|
|
2295
3535
|
nil
|
|
2296
3536
|
end
|
|
2297
3537
|
|
|
3538
|
+
def __html_ns_node__(node)
|
|
3539
|
+
el = @document.wrap_node(node)
|
|
3540
|
+
!el.respond_to?(:namespace_uri) || el.namespace_uri == HTML_NAMESPACE
|
|
3541
|
+
end
|
|
3542
|
+
|
|
2298
3543
|
def __js_get__(key)
|
|
2299
3544
|
case key
|
|
2300
3545
|
when "cells"
|
|
@@ -2325,35 +3570,47 @@ module Dommy
|
|
|
2325
3570
|
# collection + insertRow / deleteRow.
|
|
2326
3571
|
class HTMLTableSectionElement < HTMLElement
|
|
2327
3572
|
# Own __js_call__ methods, on top of Element's.
|
|
3573
|
+
HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
|
|
3574
|
+
|
|
2328
3575
|
def rows
|
|
2329
3576
|
el = self
|
|
2330
3577
|
HTMLCollection.new do
|
|
2331
|
-
el
|
|
2332
|
-
.
|
|
2333
|
-
.
|
|
2334
|
-
.select { |n| n.name == "tr" }
|
|
2335
|
-
.map { |n| el.document.wrap_node(n) }
|
|
2336
|
-
.compact
|
|
3578
|
+
el.__dommy_backend_node__.element_children
|
|
3579
|
+
.select { |n| n.name == "tr" && el.__html_ns_node__(n) }
|
|
3580
|
+
.map { |n| el.document.wrap_node(n) }.compact
|
|
2337
3581
|
end
|
|
2338
3582
|
end
|
|
2339
3583
|
|
|
2340
3584
|
def insert_row(index = -1)
|
|
3585
|
+
list = rows.to_a
|
|
3586
|
+
i = index.nil? ? -1 : index.to_i
|
|
3587
|
+
raise DOMException::IndexSizeError, "insertRow index #{i} out of range" if i < -1 || i > list.size
|
|
3588
|
+
|
|
2341
3589
|
tr = @document.create_element("tr")
|
|
2342
|
-
|
|
2343
|
-
if index.to_i == -1 || index.to_i >= list.size
|
|
3590
|
+
if i == -1 || i == list.size
|
|
2344
3591
|
append_child(tr)
|
|
2345
3592
|
else
|
|
2346
|
-
insert_before(tr, list[
|
|
3593
|
+
insert_before(tr, list[i])
|
|
2347
3594
|
end
|
|
2348
3595
|
|
|
2349
3596
|
tr
|
|
2350
3597
|
end
|
|
2351
3598
|
|
|
2352
3599
|
def delete_row(index)
|
|
2353
|
-
rows
|
|
3600
|
+
list = rows.to_a
|
|
3601
|
+
i = index.to_i
|
|
3602
|
+
raise DOMException::IndexSizeError, "deleteRow index #{i} out of range" if i < -1 || i >= list.size
|
|
3603
|
+
|
|
3604
|
+
target = i == -1 ? list.last : list[i]
|
|
3605
|
+
target&.remove
|
|
2354
3606
|
nil
|
|
2355
3607
|
end
|
|
2356
3608
|
|
|
3609
|
+
def __html_ns_node__(node)
|
|
3610
|
+
el = @document.wrap_node(node)
|
|
3611
|
+
!el.respond_to?(:namespace_uri) || el.namespace_uri == HTML_NAMESPACE
|
|
3612
|
+
end
|
|
3613
|
+
|
|
2357
3614
|
def __js_get__(key)
|
|
2358
3615
|
key == "rows" ? rows : super
|
|
2359
3616
|
end
|
|
@@ -2380,56 +3637,89 @@ module Dommy
|
|
|
2380
3637
|
# tbody elements. `insertRow(-1)` appends to the last tbody (or
|
|
2381
3638
|
# creates one); `deleteRow` works against the merged `rows` list.
|
|
2382
3639
|
class HTMLTableElement < HTMLElement
|
|
3640
|
+
HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
|
|
3641
|
+
|
|
2383
3642
|
# Own __js_call__ methods, on top of Element's.
|
|
2384
3643
|
def caption
|
|
2385
|
-
|
|
3644
|
+
first_html_child("caption")
|
|
2386
3645
|
end
|
|
2387
3646
|
|
|
2388
3647
|
def caption=(new_caption)
|
|
3648
|
+
if !new_caption.nil? && !new_caption.is_a?(HTMLTableCaptionElement)
|
|
3649
|
+
raise Bridge::TypeError, "table.caption must be an HTMLTableCaptionElement or null"
|
|
3650
|
+
end
|
|
3651
|
+
|
|
2389
3652
|
delete_caption
|
|
2390
|
-
return
|
|
3653
|
+
return if new_caption.nil?
|
|
2391
3654
|
|
|
2392
|
-
|
|
2393
|
-
|
|
3655
|
+
# Route through the validated insertion so a cycle (the caption already
|
|
3656
|
+
# containing this table) raises HierarchyRequestError and a caption from
|
|
3657
|
+
# another document is adopted, rather than corrupting the tree.
|
|
3658
|
+
insert_before(new_caption, first_child)
|
|
2394
3659
|
end
|
|
2395
3660
|
|
|
2396
3661
|
def t_head
|
|
2397
|
-
|
|
3662
|
+
first_html_child("thead")
|
|
2398
3663
|
end
|
|
2399
3664
|
|
|
2400
3665
|
def t_foot
|
|
2401
|
-
|
|
3666
|
+
first_html_child("tfoot")
|
|
2402
3667
|
end
|
|
2403
3668
|
|
|
2404
3669
|
def t_bodies
|
|
2405
3670
|
el = self
|
|
2406
3671
|
HTMLCollection.new do
|
|
2407
|
-
el
|
|
2408
|
-
.
|
|
2409
|
-
.
|
|
2410
|
-
.select { |n| n.name == "tbody" }
|
|
2411
|
-
.map { |n| el.document.wrap_node(n) }
|
|
2412
|
-
.compact
|
|
3672
|
+
el.__dommy_backend_node__.element_children
|
|
3673
|
+
.select { |n| n.name == "tbody" && el.__html_namespace_node__(n) }
|
|
3674
|
+
.map { |n| el.document.wrap_node(n) }.compact
|
|
2413
3675
|
end
|
|
2414
3676
|
end
|
|
2415
3677
|
|
|
2416
3678
|
def rows
|
|
2417
3679
|
el = self
|
|
2418
3680
|
HTMLCollection.new do
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
3681
|
+
# Per spec: thead rows first, then the tr children of the table and of
|
|
3682
|
+
# tbody sections IN TREE ORDER (a direct <tr> and a <tbody>'s rows
|
|
3683
|
+
# interleave by document position), then tfoot rows.
|
|
3684
|
+
head_rows = []
|
|
3685
|
+
body_rows = []
|
|
3686
|
+
foot_rows = []
|
|
3687
|
+
el.__dommy_backend_node__.element_children.each do |n|
|
|
3688
|
+
next unless el.__html_namespace_node__(n)
|
|
3689
|
+
|
|
3690
|
+
case n.name
|
|
3691
|
+
when "thead"
|
|
3692
|
+
el.__tr_children__(n).each { |c| head_rows << c }
|
|
3693
|
+
when "tfoot"
|
|
3694
|
+
el.__tr_children__(n).each { |c| foot_rows << c }
|
|
3695
|
+
when "tbody"
|
|
3696
|
+
el.__tr_children__(n).each { |c| body_rows << c }
|
|
3697
|
+
when "tr"
|
|
3698
|
+
body_rows << n
|
|
3699
|
+
end
|
|
2426
3700
|
end
|
|
2427
|
-
|
|
2428
|
-
direct.each { |n| ordered << n }
|
|
2429
|
-
ordered.map { |n| el.document.wrap_node(n) }.compact
|
|
3701
|
+
(head_rows + body_rows + foot_rows).map { |n| el.document.wrap_node(n) }.compact
|
|
2430
3702
|
end
|
|
2431
3703
|
end
|
|
2432
3704
|
|
|
3705
|
+
# The HTML-namespaced <tr> element children of a section node.
|
|
3706
|
+
def __tr_children__(section)
|
|
3707
|
+
section.element_children.select { |n| n.name == "tr" && __html_namespace_node__(n) }
|
|
3708
|
+
end
|
|
3709
|
+
|
|
3710
|
+
# The first HTML-namespaced element child with the given local name (a
|
|
3711
|
+
# same-name element in another namespace, e.g. SVG's <caption>, is skipped).
|
|
3712
|
+
def first_html_child(local)
|
|
3713
|
+
node = @__node__.element_children.find { |n| n.name == local && __html_namespace_node__(n) }
|
|
3714
|
+
node && @document.wrap_node(node)
|
|
3715
|
+
end
|
|
3716
|
+
|
|
3717
|
+
# Whether a raw backend node is in the HTML namespace.
|
|
3718
|
+
def __html_namespace_node__(node)
|
|
3719
|
+
el = @document.wrap_node(node)
|
|
3720
|
+
!el.respond_to?(:namespace_uri) || el.namespace_uri == HTML_NAMESPACE
|
|
3721
|
+
end
|
|
3722
|
+
|
|
2433
3723
|
def create_caption
|
|
2434
3724
|
existing = caption
|
|
2435
3725
|
return existing if existing
|
|
@@ -2521,10 +3811,51 @@ module Dommy
|
|
|
2521
3811
|
end
|
|
2522
3812
|
|
|
2523
3813
|
def delete_row(index)
|
|
2524
|
-
rows
|
|
3814
|
+
list = rows.to_a
|
|
3815
|
+
i = index.to_i
|
|
3816
|
+
raise DOMException::IndexSizeError, "deleteRow index #{i} out of range" if i < -1 || i >= list.size
|
|
3817
|
+
|
|
3818
|
+
target = i == -1 ? list.last : list[i]
|
|
3819
|
+
target&.remove
|
|
2525
3820
|
nil
|
|
2526
3821
|
end
|
|
2527
3822
|
|
|
3823
|
+
# `table.tHead = x` / `table.tFoot = x`: x must be a matching section element
|
|
3824
|
+
# (or null). It replaces the existing one at the spec position.
|
|
3825
|
+
def t_head=(value)
|
|
3826
|
+
set_table_section("thead", value)
|
|
3827
|
+
end
|
|
3828
|
+
|
|
3829
|
+
def t_foot=(value)
|
|
3830
|
+
set_table_section("tfoot", value)
|
|
3831
|
+
end
|
|
3832
|
+
|
|
3833
|
+
def set_table_section(local, value)
|
|
3834
|
+
if value.nil?
|
|
3835
|
+
first_html_child(local)&.remove
|
|
3836
|
+
return
|
|
3837
|
+
end
|
|
3838
|
+
# A non-section value fails the WebIDL type check (TypeError); a section of
|
|
3839
|
+
# the wrong local name fails the spec's algorithm (HierarchyRequestError).
|
|
3840
|
+
unless value.is_a?(HTMLTableSectionElement)
|
|
3841
|
+
raise Bridge::TypeError, "table.#{local} must be an HTMLTableSectionElement or null"
|
|
3842
|
+
end
|
|
3843
|
+
unless value.tag_name.to_s.casecmp?(local)
|
|
3844
|
+
raise DOMException::HierarchyRequestError, "table.#{local} must be a <#{local}> element"
|
|
3845
|
+
end
|
|
3846
|
+
|
|
3847
|
+
first_html_child(local)&.remove
|
|
3848
|
+
# A validated insertion (cycle → HierarchyRequestError, cross-document →
|
|
3849
|
+
# adopt). thead goes just after any caption; tfoot is appended last.
|
|
3850
|
+
if local == "thead"
|
|
3851
|
+
cap = caption
|
|
3852
|
+
insert_before(value, cap ? cap.next_sibling : first_child)
|
|
3853
|
+
else
|
|
3854
|
+
append_child(value)
|
|
3855
|
+
end
|
|
3856
|
+
value
|
|
3857
|
+
end
|
|
3858
|
+
|
|
2528
3859
|
def __js_get__(key)
|
|
2529
3860
|
case key
|
|
2530
3861
|
when "caption"
|
|
@@ -2546,6 +3877,10 @@ module Dommy
|
|
|
2546
3877
|
case key
|
|
2547
3878
|
when "caption"
|
|
2548
3879
|
self.caption = value
|
|
3880
|
+
when "tHead"
|
|
3881
|
+
self.t_head = value
|
|
3882
|
+
when "tFoot"
|
|
3883
|
+
self.t_foot = value
|
|
2549
3884
|
else
|
|
2550
3885
|
super
|
|
2551
3886
|
end
|
|
@@ -2561,18 +3896,22 @@ module Dommy
|
|
|
2561
3896
|
insert_row(args[0] || -1)
|
|
2562
3897
|
when "deleteRow"
|
|
2563
3898
|
delete_row(args[0])
|
|
3899
|
+
Bridge::UNDEFINED
|
|
2564
3900
|
when "createCaption"
|
|
2565
3901
|
create_caption
|
|
2566
3902
|
when "deleteCaption"
|
|
2567
3903
|
delete_caption
|
|
3904
|
+
Bridge::UNDEFINED
|
|
2568
3905
|
when "createTHead"
|
|
2569
3906
|
create_t_head
|
|
2570
3907
|
when "deleteTHead"
|
|
2571
3908
|
delete_t_head
|
|
3909
|
+
Bridge::UNDEFINED
|
|
2572
3910
|
when "createTFoot"
|
|
2573
3911
|
create_t_foot
|
|
2574
3912
|
when "deleteTFoot"
|
|
2575
3913
|
delete_t_foot
|
|
3914
|
+
Bridge::UNDEFINED
|
|
2576
3915
|
when "createTBody"
|
|
2577
3916
|
create_t_body
|
|
2578
3917
|
else
|
|
@@ -2910,19 +4249,45 @@ module Dommy
|
|
|
2910
4249
|
set_reflected_string("height", v.to_s)
|
|
2911
4250
|
end
|
|
2912
4251
|
|
|
2913
|
-
#
|
|
2914
|
-
#
|
|
2915
|
-
# resource)
|
|
4252
|
+
# The nested browsing context's document. An integration/test layer may
|
|
4253
|
+
# inject one via `__internal_set_content_document__` (e.g. the `src`
|
|
4254
|
+
# resource); otherwise a connected iframe gets a lazily-created blank
|
|
4255
|
+
# about:blank document (with its own Window), matching a browser where
|
|
4256
|
+
# `iframe.contentDocument` is non-null once the frame is in a document.
|
|
4257
|
+
# A disconnected iframe has no browsing context, so contentDocument is null.
|
|
2916
4258
|
def content_document
|
|
2917
|
-
@content_document
|
|
4259
|
+
return @content_document if @content_document
|
|
4260
|
+
return nil unless respond_to?(:is_connected?) && is_connected?
|
|
4261
|
+
# An iframe with a `src` (or `srcdoc`) is navigated by the host / test layer
|
|
4262
|
+
# (Dommy doesn't fetch), which injects the document via
|
|
4263
|
+
# `__internal_set_content_document__`; only a truly blank iframe gets the
|
|
4264
|
+
# auto about:blank document here, so we don't shadow a pending navigation.
|
|
4265
|
+
return nil unless get_attribute("src").to_s.empty? && get_attribute("srcdoc").nil?
|
|
4266
|
+
|
|
4267
|
+
@content_document = build_blank_content_document
|
|
4268
|
+
end
|
|
4269
|
+
|
|
4270
|
+
# Build the blank nested document + its Window, back-linking the Window to
|
|
4271
|
+
# this frame (so getComputedStyle can detect a non-rendered frame's content).
|
|
4272
|
+
def build_blank_content_document
|
|
4273
|
+
win = Window.new(nil, backend_doc: Backend.parse("<!DOCTYPE html><html><head></head><body></body></html>"))
|
|
4274
|
+
doc = win.document
|
|
4275
|
+
win.frame_element = self if win.respond_to?(:frame_element=)
|
|
4276
|
+
doc
|
|
2918
4277
|
end
|
|
2919
4278
|
|
|
2920
4279
|
def __internal_set_content_document__(doc)
|
|
2921
4280
|
@content_document = doc
|
|
4281
|
+
# Back-link the nested window to its hosting frame, so getComputedStyle can
|
|
4282
|
+
# detect content inside a non-rendered (display:none / disconnected) frame.
|
|
4283
|
+
view = doc.respond_to?(:default_view) ? doc.default_view : nil
|
|
4284
|
+
view.frame_element = self if view.respond_to?(:frame_element=)
|
|
2922
4285
|
end
|
|
2923
4286
|
|
|
2924
4287
|
def content_window
|
|
2925
|
-
|
|
4288
|
+
# Go through the lazy accessor so a blank browsing context is created on
|
|
4289
|
+
# first `contentWindow` access too (not only via `contentDocument`).
|
|
4290
|
+
content_document&.default_view
|
|
2926
4291
|
end
|
|
2927
4292
|
|
|
2928
4293
|
def __js_get__(key)
|
|
@@ -3021,7 +4386,41 @@ module Dommy
|
|
|
3021
4386
|
reflect_string :value
|
|
3022
4387
|
end
|
|
3023
4388
|
|
|
4389
|
+
# Element interfaces that are otherwise plain HTMLElement subclasses — their
|
|
4390
|
+
# own IDL adds little beyond the base, but they must be distinct types so
|
|
4391
|
+
# `createElement("col") instanceof HTMLTableColElement` (and cloneNode
|
|
4392
|
+
# identity) holds. `col`/`colgroup` share HTMLTableColElement per spec.
|
|
4393
|
+
class HTMLTableColElement < HTMLElement; end
|
|
4394
|
+
class HTMLDataListElement < HTMLElement
|
|
4395
|
+
# `options` — the <option> descendants, as a live HTMLCollection.
|
|
4396
|
+
def options
|
|
4397
|
+
el = self
|
|
4398
|
+
HTMLCollection.new do
|
|
4399
|
+
el.__dommy_backend_node__.css("option").map { |n| el.document.wrap_node(n) }.compact
|
|
4400
|
+
end
|
|
4401
|
+
end
|
|
4402
|
+
|
|
4403
|
+
def __js_get__(key)
|
|
4404
|
+
return options if key == "options"
|
|
4405
|
+
|
|
4406
|
+
super
|
|
4407
|
+
end
|
|
4408
|
+
end
|
|
4409
|
+
class HTMLDirectoryElement < HTMLElement; end
|
|
4410
|
+
class HTMLDListElement < HTMLElement; end
|
|
4411
|
+
class HTMLFontElement < HTMLElement
|
|
4412
|
+
reflect_string :color, :face, :size
|
|
4413
|
+
end
|
|
4414
|
+
class HTMLFrameElement < HTMLElement; end
|
|
4415
|
+
class HTMLFrameSetElement < HTMLElement
|
|
4416
|
+
include WindowReflectingHandlers
|
|
4417
|
+
end
|
|
4418
|
+
class HTMLParamElement < HTMLElement
|
|
4419
|
+
reflect_string :name, :value
|
|
4420
|
+
end
|
|
4421
|
+
|
|
3024
4422
|
class HTMLAreaElement < HTMLElement
|
|
4423
|
+
include HyperlinkActivation
|
|
3025
4424
|
reflect_string :alt, :coords, :shape, :href, :target, :rel
|
|
3026
4425
|
end
|
|
3027
4426
|
|
|
@@ -3263,6 +4662,7 @@ module Dommy
|
|
|
3263
4662
|
end
|
|
3264
4663
|
|
|
3265
4664
|
class HTMLBodyElement < HTMLElement
|
|
4665
|
+
include WindowReflectingHandlers
|
|
3266
4666
|
end
|
|
3267
4667
|
|
|
3268
4668
|
class HTMLHeadElement < HTMLElement
|
|
@@ -3294,7 +4694,7 @@ module Dommy
|
|
|
3294
4694
|
"optgroup" => HTMLOptGroupElement,
|
|
3295
4695
|
"textarea" => HTMLTextAreaElement,
|
|
3296
4696
|
"label" => HTMLLabelElement,
|
|
3297
|
-
"fieldset" =>
|
|
4697
|
+
"fieldset" => HTMLFieldSetElement,
|
|
3298
4698
|
"output" => HTMLOutputElement,
|
|
3299
4699
|
"legend" => HTMLLegendElement,
|
|
3300
4700
|
"slot" => HTMLSlotElement,
|
|
@@ -3348,7 +4748,16 @@ module Dommy
|
|
|
3348
4748
|
"pre" => HTMLPreElement,
|
|
3349
4749
|
"body" => HTMLBodyElement,
|
|
3350
4750
|
"head" => HTMLHeadElement,
|
|
3351
|
-
"html" => HTMLHtmlElement
|
|
4751
|
+
"html" => HTMLHtmlElement,
|
|
4752
|
+
"col" => HTMLTableColElement,
|
|
4753
|
+
"colgroup" => HTMLTableColElement,
|
|
4754
|
+
"datalist" => HTMLDataListElement,
|
|
4755
|
+
"dir" => HTMLDirectoryElement,
|
|
4756
|
+
"dl" => HTMLDListElement,
|
|
4757
|
+
"font" => HTMLFontElement,
|
|
4758
|
+
"frame" => HTMLFrameElement,
|
|
4759
|
+
"frameset" => HTMLFrameSetElement,
|
|
4760
|
+
"param" => HTMLParamElement
|
|
3352
4761
|
}.freeze
|
|
3353
4762
|
|
|
3354
4763
|
SVG_NAMESPACE_URI = "http://www.w3.org/2000/svg"
|