dommy 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
@@ -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
- # HTML attribute names are case-insensitive the browser DOM
11
- # lowercases everything. Override to make this explicit at the
12
- # HTMLElement level (Element's default would already pick this up
13
- # via namespace inspection, but spelling it out shortcuts the
14
- # namespace check for HTML's hot path).
15
- def case_sensitive_attribute_names?
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
- HTMLCollection.new do
132
- el
133
- .__dommy_backend_node__
134
- .css("input, select, textarea, button, output, fieldset")
135
- .map do |n|
136
- el.document.wrap_node(n)
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
- # firing a `submit` event. This is the JS-only entry point
148
- # browsers don't run constraint validation either. Dommy has no
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?)` is the JS counterpart that
162
- # MIRRORS user-initiated submission it runs constraint validation
163
- # and fires a `submit` event. Returns true if not default-prevented.
164
- # `submitter` (if given) must be a button inside this form.
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
- dispatch_event(Event.new("submit", "bubbles" => true, "cancelable" => true))
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
- sanitize_value(@__value.nil? ? reflected_string("value") : @__value)
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
- raw.to_s.split(",").map(&:strip).join(",")
570
+ stripped.split(",").map(&:strip).join(",")
281
571
  else
282
- raw.to_s.strip
572
+ stripped.strip
283
573
  end
284
-
285
574
  when "url"
286
- raw.to_s.strip
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
- # The radio button group: radios sharing this element's non-empty name and
404
- # form owner (or, with no form, no form either). Orphan-tree grouping for
405
- # detached radios is not modeled.
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
- owner_form = form
411
- scope = owner_form || @document
412
- return [self] unless scope.respond_to?(:query_selector_all)
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
- scope.query_selector_all("input[type='radio']").to_a.select do |radio|
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
- return [] if id.empty?
730
+ # A hidden input is not a labelable element, so it has no labels list.
731
+ return nil if type == "hidden"
421
732
 
422
- @document.query_selector_all("label[for='#{id}']")
733
+ labels_node_list
423
734
  end
424
735
 
425
- # Closest enclosing form (or nil if detached / not in a form).
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
- # No real text selection; method stubs let callers proceed.
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(_start, _end, _direction = nil)
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
- def step_up(_n = 1)
444
- nil
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
- def step_down(_n = 1)
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
- return [] if id.empty?
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
- # Buttons don't participate in constraint validation (per spec).
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
- false
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
- EMAIL_RE = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
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", "radio"
915
- !host_attr_present?("checked")
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
- host_value.to_s.empty?
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
- !v.match?(EMAIL_RE)
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
- return false unless @host
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
- return false unless @host
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 = host_attr_value("min").to_s
980
- return false if min.empty?
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 = numeric_value
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 = host_attr_value("max").to_s
990
- return false if max.empty?
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
- num = numeric_value
993
- num && num > max.to_f
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 = host_attr_value("step").to_s
1000
- return false if step.empty? || step == "any"
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 = numeric_value
1006
- return false unless num
1869
+ num = @host.value_as_number
1870
+ return false if num.nan?
1007
1871
 
1008
- base = host_attr_value("min").to_s
1009
- base_n = base.empty? ? 0.0 : base.to_f
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) && %w[number range].include?(host_type)
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 :selected, :disabled
2012
+ reflect_boolean :disabled
1143
2013
  def value
1144
- # Per spec, value defaults to text content if the `value`
1145
- # attribute is absent.
1146
- @__node__.key?("value") ? @__node__["value"].to_s : text_content
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
- def value=(v)
1150
- set_reflected_string("value", v)
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
- def label
1154
- @__node__.key?("label") ? @__node__["label"].to_s : text_content
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
- def label=(v)
1158
- set_reflected_string("label", v)
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 default_selected
1162
- selected
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 default_selected=(v)
1166
- self.selected = v
2082
+ def sync_selectedness_from_attribute
2083
+ @selectedness = default_selected unless @selectedness_dirty
1167
2084
  end
1168
2085
 
1169
2086
  def text
1170
- text_content
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", "defaultSelected"
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
- @__node__["value"] || text_content
2192
+ @__value_dirty ? @__value.to_s : default_value
1236
2193
  end
1237
2194
 
1238
2195
  def value=(v)
1239
- @__node__["value"] = v.to_s
1240
- self.text_content = v.to_s
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
- (@__node__["maxlength"] || "-1").to_i
2242
+ parse_non_negative_reflected("maxlength")
1269
2243
  end
1270
2244
 
1271
2245
  def min_length
1272
- (@__node__["minlength"] || "-1").to_i
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
- return [] if id.empty?
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") && !reflected_boolean("readonly")
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
- set_reflected_string("maxlength", v.to_s)
2368
+ self.max_length = v
1384
2369
  when "minLength"
1385
- set_reflected_string("minlength", v.to_s)
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
- query_selector("input, select, textarea, button, output, meter, progress")
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 HTMLFieldsetElement < HTMLElement
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
- self.text_content = v
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
- self.text_content = v
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
- return [] if id.empty?
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 do
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
- # `selectedIndex` first option with `selected`, or 0 if none and
1739
- # not multiple, or -1 if multiple and none.
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
- idx = opts.find_index { |o| o.__dommy_backend_node__.key?("selected") }
1743
- return idx if idx
2829
+ opts = options.to_a
2830
+ sel = __display_selected__.first
2831
+ return -1 unless sel
1744
2832
 
1745
- multiple ? -1 : (opts.empty? ? -1 : 0)
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
- opts = options
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
- opts = options
1762
- sel = opts.find { |o| o.__dommy_backend_node__.key?("selected") } || opts.first
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
- target = options.find { |o| (o.__dommy_backend_node__["value"] || o.text_content).to_s == new_value.to_s }
1768
- return unless target
1769
-
1770
- options.each { |o| o.remove_attribute("selected") if o.__dommy_backend_node__.key?("selected") }
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
- target = options[i.to_i]
1797
- target&.remove
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
- dispatch_event(Event.new("close", "bubbles" => false, "cancelable" => false))
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
- dispatch_event(Event.new("toggle", "bubbles" => false, "cancelable" => false)) if open != was
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
- def value
2017
- numeric_attr("value", 0.0)
2018
- end
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.to_s)
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.to_s)
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.to_s)
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.to_s)
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.to_s)
3237
+ set_reflected_string("optimum", format_double(restricted_double(v)))
2062
3238
  end
2063
3239
 
2064
3240
  def labels
2065
- return [] if id.empty?
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", "min", "max", "low", "high", "optimum"
2094
- set_reflected_string(key, v.to_s)
2095
- else
2096
- super
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? ? nil : Float(raw)
2115
- rescue ArgumentError
2116
- nil
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
- set_reflected_string("max", v.to_s)
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 progress; -1 if value
2133
- # was never set (indeterminate).
3351
+ # `position` = value/max for a determinate bar; -1 for an indeterminate one
3352
+ # (no parseable value content attribute).
2134
3353
  def position
2135
- v = value
2136
- return -1.0 if v.nil?
3354
+ return -1.0 unless determinate?
2137
3355
 
2138
- m = max
2139
- m <= 0 ? 1.0 : (v / m)
3356
+ value / max
2140
3357
  end
2141
3358
 
2142
3359
  def labels
2143
- return [] if id.empty?
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", "max"
2166
- set_reflected_string(key, v.to_s)
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 = closest("tr")
2198
- return -1 unless row
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
- .__dommy_backend_node__
2257
- .element_children
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
- return -1 unless table
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
- section = @__node__.parent
2273
- return -1 unless section && section.element? && %w[thead tbody tfoot].include?(section.name)
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
- section.element_children.select { |n| n.name == "tr" }.find_index { |n| n == @__node__ } || -1
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
- # (defaults to end). Returns the new cell.
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
- list = cells
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[index.to_i])
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
- target = cells[index.to_i]
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
- .__dommy_backend_node__
2333
- .element_children
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
- list = rows
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[index.to_i])
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[index.to_i]&.remove
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
- @__node__.element_children.find { |n| n.name == "caption" }&.then { |n| @document.wrap_node(n) }
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 unless new_caption.respond_to?(:__dommy_backend_node__)
3653
+ return if new_caption.nil?
2391
3654
 
2392
- first = @__node__.children.first
2393
- first ? first.add_previous_sibling(new_caption.__dommy_backend_node__) : @__node__.add_child(new_caption.__dommy_backend_node__)
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
- @__node__.element_children.find { |n| n.name == "thead" }&.then { |n| @document.wrap_node(n) }
3662
+ first_html_child("thead")
2398
3663
  end
2399
3664
 
2400
3665
  def t_foot
2401
- @__node__.element_children.find { |n| n.name == "tfoot" }&.then { |n| @document.wrap_node(n) }
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
- .__dommy_backend_node__
2409
- .element_children
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
- ordered = []
2420
- head = el.__dommy_backend_node__.element_children.find { |n| n.name == "thead" }
2421
- bodies = el.__dommy_backend_node__.element_children.select { |n| n.name == "tbody" }
2422
- direct = el.__dommy_backend_node__.element_children.select { |n| n.name == "tr" }
2423
- foot = el.__dommy_backend_node__.element_children.find { |n| n.name == "tfoot" }
2424
- [head, *bodies, foot].compact.each do |sec|
2425
- sec.element_children.select { |n| n.name == "tr" }.each { |n| ordered << n }
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[index.to_i]&.remove
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
- # Dommy doesn't navigate iframes itself, but an integration/test layer can
2914
- # populate the nested browsing context's document (e.g. parsing the `src`
2915
- # resource) via `__internal_set_content_document__`.
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
- @content_document&.default_view
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" => HTMLFieldsetElement,
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"