dommy 0.8.0 → 0.9.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
@@ -15,6 +15,19 @@ module Dommy
15
15
  def case_sensitive_attribute_names?
16
16
  false
17
17
  end
18
+
19
+ private
20
+
21
+ # HTML "rules for parsing integers": optional leading ASCII whitespace, an
22
+ # optional sign, then ASCII digits (trailing junk allowed). Returns the
23
+ # integer, or nil when the value is absent or doesn't begin with a valid
24
+ # integer — callers supply the reflected attribute's default.
25
+ def parse_html_integer(value)
26
+ return nil if value.nil?
27
+
28
+ match = value.to_s.sub(/\A[ \t\n\f\r]+/, "").match(/\A[-+]?\d+/)
29
+ match ? match[0].to_i : nil
30
+ end
18
31
  end
19
32
 
20
33
  # `<a>` — exposes URL-component getters/setters via the `href`
@@ -302,6 +315,105 @@ module Dommy
302
315
 
303
316
  def checked=(v)
304
317
  @__checked = !!v
318
+ # A radio becoming checked unchecks the rest of its radio button group.
319
+ uncheck_radio_group if @__checked && type == "radio"
320
+ # Checkedness is property state (no attribute mutation fires), yet it
321
+ # is selector-observable via :checked — invalidate computed styles.
322
+ @document&.__internal_bump_style_generation__
323
+ end
324
+
325
+ # `indeterminate` is pure property state (no content attribute), default
326
+ # false. Selector-observable via :indeterminate, so bump style generation.
327
+ def indeterminate
328
+ @__indeterminate || false
329
+ end
330
+
331
+ def indeterminate=(v)
332
+ @__indeterminate = !!v
333
+ @document&.__internal_bump_style_generation__
334
+ end
335
+
336
+ # --- Click activation behavior (checkbox / radio) -------------------
337
+
338
+ # HTML pre-click activation: a checkbox toggles; a radio becomes checked
339
+ # (which unchecks its group). Returns the state needed to undo this if the
340
+ # click is canceled, or nil for inputs with no activation behavior.
341
+ def pre_click_activation_state
342
+ # Only a mutable (enabled) checkbox/radio has activation behavior.
343
+ return nil if disabled
344
+
345
+ case type
346
+ when "checkbox"
347
+ old = checked
348
+ old_indeterminate = indeterminate
349
+ # Pre-click activation clears indeterminate, then toggles checkedness.
350
+ self.indeterminate = false
351
+ self.checked = !old
352
+ { kind: :checkbox, old_checked: old, old_indeterminate: old_indeterminate }
353
+ when "radio"
354
+ old_checked = checked
355
+ previously_checked = old_checked ? nil : currently_checked_in_radio_group
356
+ self.checked = true
357
+ { kind: :radio, old_checked: old_checked, previously_checked: previously_checked }
358
+ end
359
+ end
360
+
361
+ # Not canceled: fire `input` then `change` (HTML "input activation
362
+ # behavior"). Both are UA-generated, so trusted and non-cancelable.
363
+ def run_post_click_activation(_state)
364
+ dispatch_event(Event.new("input", "bubbles" => true).__internal_mark_trusted__)
365
+ dispatch_event(Event.new("change", "bubbles" => true).__internal_mark_trusted__)
366
+ end
367
+
368
+ # Canceled (default prevented): restore the pre-click checkedness. For a
369
+ # radio, also re-check whichever member was checked before.
370
+ def restore_pre_click_activation(state)
371
+ case state[:kind]
372
+ when :checkbox
373
+ self.checked = state[:old_checked]
374
+ self.indeterminate = state[:old_indeterminate]
375
+ when :radio
376
+ self.checked = state[:old_checked]
377
+ prev = state[:previously_checked]
378
+ prev.checked = true if prev && !prev.equal?(self)
379
+ end
380
+ end
381
+
382
+ # The currently-checked radio in this element's group (or nil).
383
+ def currently_checked_in_radio_group
384
+ radio_group_members.find { |radio| radio.checked && !radio.equal?(self) }
385
+ end
386
+
387
+ # Uncheck every other radio in this element's group.
388
+ def uncheck_radio_group
389
+ radio_group_members.each do |radio|
390
+ next if radio.equal?(self)
391
+
392
+ radio.__internal_set_checked_silently__(false)
393
+ end
394
+ end
395
+
396
+ # Set checkedness without re-running the group cascade (used while
397
+ # unchecking peers).
398
+ def __internal_set_checked_silently__(value)
399
+ @__checked = !!value
400
+ @document&.__internal_bump_style_generation__
401
+ end
402
+
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.
406
+ def radio_group_members
407
+ group_name = get_attribute("name").to_s
408
+ return [self] if group_name.empty?
409
+
410
+ owner_form = form
411
+ scope = owner_form || @document
412
+ return [self] unless scope.respond_to?(:query_selector_all)
413
+
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)
416
+ end
305
417
  end
306
418
 
307
419
  def labels
@@ -386,6 +498,8 @@ module Dommy
386
498
  value
387
499
  when "checked"
388
500
  checked
501
+ when "indeterminate"
502
+ indeterminate
389
503
  when "readonly", "readOnly"
390
504
  readonly
391
505
  when "labels"
@@ -413,6 +527,8 @@ module Dommy
413
527
  self.value = value
414
528
  when "checked"
415
529
  self.checked = value
530
+ when "indeterminate"
531
+ self.indeterminate = value
416
532
  when "readonly", "readOnly"
417
533
  self.readonly = value
418
534
  else
@@ -623,25 +739,111 @@ module Dommy
623
739
  super
624
740
  end
625
741
  end
742
+
743
+ # The classic-script source to execute now that this element is connected,
744
+ # or nil if it must not run: a `src` script (no network here), a non-classic
745
+ # type (module/JSON/etc.), an empty body, or one that already ran. The
746
+ # "already started" flag (set on first call) makes execution happen at most
747
+ # once, even if the node is re-inserted. The host eval is wired by the JS
748
+ # bridge via Document#script_runner.
749
+ # Module scripts are excluded — they need module scope / import resolution
750
+ # the classic eval path can't provide (left to a future module loader).
751
+ CLASSIC_SCRIPT_TYPES = ["", "text/javascript", "application/javascript",
752
+ "application/ecmascript", "text/ecmascript"].freeze
753
+
754
+ # Set the HTML "already started" flag without running anything. The fragment
755
+ # parsing algorithm (innerHTML / insertAdjacentHTML / outerHTML / DOMParser)
756
+ # flags its scripts this way so they never execute on insertion.
757
+ def __internal_mark_script_already_started__
758
+ @__script_started = true
759
+ nil
760
+ end
761
+
762
+ def __internal_take_pending_script__
763
+ return nil if @__script_started
764
+ return nil unless src.to_s.empty?
765
+ return nil unless CLASSIC_SCRIPT_TYPES.include?(type.to_s.strip.downcase)
766
+
767
+ body = text_content.to_s
768
+ return nil if body.strip.empty?
769
+
770
+ @__script_started = true
771
+ body
772
+ end
773
+
774
+ # The classic-script `src` to fetch and execute now, or nil if it must not
775
+ # run: an inline script (no src), a non-classic type (module/JSON/etc.), or
776
+ # one that already ran. The external counterpart of
777
+ # #__internal_take_pending_script__ — it sets the same "already started"
778
+ # flag so the host fetches/executes the external body at most once, even if
779
+ # the node is re-inserted. The fetch itself is the host's job (Dommy has no
780
+ # network); this only decides eligibility and returns the URL.
781
+ def __internal_take_pending_src__
782
+ return nil if @__script_started
783
+
784
+ s = src.to_s
785
+ return nil if s.empty?
786
+ return nil unless CLASSIC_SCRIPT_TYPES.include?(type.to_s.strip.downcase)
787
+
788
+ @__script_started = true
789
+ s
790
+ end
791
+
792
+ # A `type="module"` script to evaluate now, or nil if it must not run (a
793
+ # non-module type, an empty inline body, or one that already ran). Returns
794
+ # `[:inline, body]` or `[:external, src]`; the host evaluates it as an ES
795
+ # module (resolving its imports). Sets the same "already started" flag so a
796
+ # module runs at most once.
797
+ def __internal_take_pending_module__
798
+ return nil if @__script_started
799
+ return nil unless type.to_s.strip.downcase == "module"
800
+
801
+ s = src.to_s
802
+ if s.empty?
803
+ body = text_content.to_s
804
+ return nil if body.strip.empty?
805
+
806
+ @__script_started = true
807
+ [:inline, body]
808
+ else
809
+ @__script_started = true
810
+ [:external, s]
811
+ end
812
+ end
626
813
  end
627
814
 
628
815
  # `<link>` — primarily for stylesheets, icons, preload, manifests.
629
816
  class HTMLLinkElement < HTMLElement
630
817
  reflect_string :href, :rel, :type, :media, :sizes, :hreflang, :integrity, as_attr: { attr: "as", js: "as" }, crossorigin: { js: "crossOrigin" }, referrer_policy: "referrerpolicy"
631
818
  # `link.sheet` — non-nil only when this link is a stylesheet
632
- # (`rel` contains "stylesheet"). The sheet itself is a stub:
633
- # Dommy doesn't fetch or parse CSS, but consumers can still
634
- # `insertRule` / `deleteRule` against the in-memory sheet.
819
+ # (`rel` contains "stylesheet"). Dommy fetches nothing itself, so the
820
+ # sheet starts empty; a host environment supplies the CSS via
821
+ # `set_stylesheet_text`. Once filled it participates in the cascade and
822
+ # `insertRule` / `deleteRule` work against it like any CSSOM sheet.
635
823
  def sheet
636
- return nil unless rel.split(/\s+/).any? { |t| t.casecmp("stylesheet").zero? }
824
+ return nil unless stylesheet_rel?
637
825
 
638
- @__sheet ||= CSSStyleSheet.new(
639
- owner_node: self,
640
- href: href,
641
- media: media,
642
- title: @__node__["title"].to_s,
643
- type: (type.empty? ? "text/css" : type)
644
- )
826
+ @__sheet ||= build_link_sheet
827
+ end
828
+
829
+ # Host hook (e.g. dommy-rack resolving `<link href>` from a response):
830
+ # supply the CSS this link points at. Re-seeds the sheet — splitting the
831
+ # text into CSSRules and invalidates the document's computed styles so
832
+ # the next getComputedStyle / visible? sees the new rules.
833
+ def set_stylesheet_text(css)
834
+ return nil unless stylesheet_rel?
835
+
836
+ @__sheet = build_link_sheet(css.to_s)
837
+ doc = owner_document
838
+ doc.__internal_bump_style_generation__ if doc.respond_to?(:__internal_bump_style_generation__)
839
+ @__sheet
840
+ end
841
+
842
+ # The sheet the cascade should read, or nil when this link isn't a
843
+ # stylesheet or no one instantiated/filled its sheet yet. (Unlike
844
+ # `sheet`, this never instantiates — an untouched link costs nothing.)
845
+ def __internal_stylesheet_for_cascade__
846
+ @__sheet if @__sheet && stylesheet_rel?
645
847
  end
646
848
 
647
849
  def __js_get__(key)
@@ -655,6 +857,23 @@ module Dommy
655
857
  super
656
858
  end
657
859
  end
860
+
861
+ private
862
+
863
+ def stylesheet_rel?
864
+ rel.split(/\s+/).any? { |token| token.casecmp("stylesheet").zero? }
865
+ end
866
+
867
+ def build_link_sheet(source_text = nil)
868
+ CSSStyleSheet.new(
869
+ owner_node: self,
870
+ href: href,
871
+ media: media,
872
+ title: @__node__["title"].to_s,
873
+ type: (type.empty? ? "text/css" : type),
874
+ source_text: source_text
875
+ )
876
+ end
658
877
  end
659
878
 
660
879
  # `ValidityState` — computes constraint-validation flags from the
@@ -862,6 +1081,8 @@ module Dommy
862
1081
  custom_error
863
1082
  when "valid"
864
1083
  valid
1084
+ else
1085
+ Bridge::ABSENT
865
1086
  end
866
1087
  end
867
1088
 
@@ -1743,20 +1964,27 @@ module Dommy
1743
1964
  end
1744
1965
  end
1745
1966
 
1746
- # `<details>` — `open` reflected boolean. Toggling it fires a
1747
- # `toggle` event (non-bubbling per spec).
1967
+ # `<details>` — `open` reflected boolean. Whenever the open state changes —
1968
+ # via the `open` property, setAttribute/removeAttribute, or toggleAttribute —
1969
+ # a non-bubbling `toggle` event fires (per spec). Routing the dispatch through
1970
+ # the attribute mutators (not just the property setter) is what makes
1971
+ # `details.toggleAttribute("open")` fire toggle, which Stimulus's `:open`
1972
+ # action option relies on.
1748
1973
  class HTMLDetailsElement < HTMLElement
1749
1974
  def open
1750
1975
  reflected_boolean("open")
1751
1976
  end
1752
1977
 
1753
1978
  def open=(v)
1754
- was = open
1755
1979
  set_reflected_boolean("open", v)
1756
- now = open
1757
- return if was == now
1980
+ end
1981
+
1982
+ def set_attribute(name, value)
1983
+ with_toggle_on_open_change { super }
1984
+ end
1758
1985
 
1759
- dispatch_event(Event.new("toggle", "bubbles" => false, "cancelable" => false))
1986
+ def remove_attribute(name)
1987
+ with_toggle_on_open_change { super }
1760
1988
  end
1761
1989
 
1762
1990
  def __js_get__(key)
@@ -1770,6 +1998,15 @@ module Dommy
1770
1998
  super
1771
1999
  end
1772
2000
  end
2001
+
2002
+ private
2003
+
2004
+ def with_toggle_on_open_change
2005
+ was = open
2006
+ result = yield
2007
+ dispatch_event(Event.new("toggle", "bubbles" => false, "cancelable" => false)) if open != was
2008
+ result
2009
+ end
1773
2010
  end
1774
2011
 
1775
2012
  # `<meter>` — gauge with `value` / `min` / `max` (default 0/0/1)
@@ -2726,8 +2963,9 @@ module Dommy
2726
2963
  class HTMLOListElement < HTMLElement
2727
2964
  reflect_string :type
2728
2965
  reflect_boolean :reversed
2966
+ # `start` reflects the content attribute as a long with default 1.
2729
2967
  def start
2730
- (@__node__["start"] || "1").to_i
2968
+ parse_html_integer(@__node__["start"]) || 1
2731
2969
  end
2732
2970
 
2733
2971
  def start=(v)
@@ -2757,8 +2995,9 @@ module Dommy
2757
2995
  end
2758
2996
 
2759
2997
  class HTMLLIElement < HTMLElement
2998
+ # `value` reflects the content attribute as a long with default 0.
2760
2999
  def value
2761
- @__node__["value"]&.to_i
3000
+ parse_html_integer(@__node__["value"]) || 0
2762
3001
  end
2763
3002
 
2764
3003
  def value=(v)
@@ -2925,18 +3164,33 @@ module Dommy
2925
3164
  @__disabled = !!v
2926
3165
  end
2927
3166
 
2928
- # `style.sheet` — always non-nil for `<style>` (in browsers the
2929
- # text content is parsed; we hand back an empty sheet stub that
2930
- # consumers can manipulate via insertRule/deleteRule).
3167
+ # `style.sheet` — always non-nil for `<style>`. Memoized per text
3168
+ # content (CSSOM: repeated reads return the same object), seeded
3169
+ # with the element's CSS text so insertRule/deleteRule order against
3170
+ # it. Rewriting the element's text discards the sheet and any rules
3171
+ # inserted via CSSOM — browsers re-parse into a fresh sheet too.
2931
3172
  def sheet
2932
- @__sheet ||= CSSStyleSheet.new(
3173
+ text = text_content.to_s
3174
+ return @__sheet if @__sheet && @__sheet_text == text
3175
+
3176
+ @__sheet_text = text
3177
+ @__sheet = CSSStyleSheet.new(
2933
3178
  owner_node: self,
2934
3179
  media: media,
2935
3180
  title: @__node__["title"].to_s,
2936
- type: (type.empty? ? "text/css" : type)
3181
+ type: (type.empty? ? "text/css" : type),
3182
+ source_text: text
2937
3183
  )
2938
3184
  end
2939
3185
 
3186
+ # The memoized sheet, or nil when none was created yet or the
3187
+ # element's text changed since (stale sheet). Lets the cascade read
3188
+ # CSSOM state only for sheets someone actually touched, without
3189
+ # instantiating sheet objects for every `<style>` in the document.
3190
+ def __internal_instantiated_sheet__
3191
+ @__sheet if @__sheet && @__sheet_text == text_content.to_s
3192
+ end
3193
+
2940
3194
  def __js_get__(key)
2941
3195
  case key
2942
3196
  when "disabled"
@@ -3016,7 +3270,14 @@ module Dommy
3016
3270
 
3017
3271
  class HTMLHtmlElement < HTMLElement
3018
3272
  end
3273
+ end
3274
+
3275
+ # HTMLCanvasElement is a HTMLElement subclass kept in its own file (the canvas
3276
+ # 2D-context stub is sizeable); required here so the element map below resolves
3277
+ # the constant. Its runtime-only references (Blob, …) load with the rest.
3278
+ require_relative "html_canvas_element"
3019
3279
 
3280
+ module Dommy
3020
3281
  # Look up the subclass for a given HTML tag. Document#wrap_node
3021
3282
  # consults this map; defaults to plain Element.
3022
3283
  HTML_ELEMENT_CLASSES = {
@@ -3025,6 +3286,7 @@ module Dommy
3025
3286
  "input" => HTMLInputElement,
3026
3287
  "button" => HTMLButtonElement,
3027
3288
  "img" => HTMLImageElement,
3289
+ "canvas" => HTMLCanvasElement,
3028
3290
  "script" => HTMLScriptElement,
3029
3291
  "link" => HTMLLinkElement,
3030
3292
  "select" => HTMLSelectElement,
@@ -3090,12 +3352,27 @@ module Dommy
3090
3352
  }.freeze
3091
3353
 
3092
3354
  SVG_NAMESPACE_URI = "http://www.w3.org/2000/svg"
3355
+ HTML_NAMESPACE_URI = "http://www.w3.org/1999/xhtml"
3356
+
3357
+ # The interface for an HTML-namespace element whose local name maps to no
3358
+ # specialized interface (e.g. createElementNS with an upper-case or otherwise
3359
+ # unrecognized name). Still an HTMLElement, so `instanceof HTMLElement` holds.
3360
+ class HTMLUnknownElement < HTMLElement
3361
+ end
3093
3362
 
3363
+ # Map a (local name, namespace) pair to its DOM interface class. HTML-namespace
3364
+ # names match case-SENSITIVELY (createElement lower-cases first, but
3365
+ # createElementNS preserves case, so "SPAN" is unknown); any namespace other
3366
+ # than HTML/SVG — including the null namespace — gets the generic Element.
3094
3367
  def self.element_class_for(tag_name, namespace_uri = nil)
3095
- if namespace_uri == SVG_NAMESPACE_URI
3096
- SVG_ELEMENT_CLASSES[tag_name.to_s.downcase] || SVGElement
3368
+ name = tag_name.to_s
3369
+ case namespace_uri
3370
+ when SVG_NAMESPACE_URI
3371
+ SVG_ELEMENT_CLASSES[name.downcase] || SVGElement
3372
+ when HTML_NAMESPACE_URI
3373
+ HTML_ELEMENT_CLASSES[name] || HTMLUnknownElement
3097
3374
  else
3098
- HTML_ELEMENT_CLASSES[tag_name.to_s.downcase] || Element
3375
+ Element
3099
3376
  end
3100
3377
  end
3101
3378
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Interaction
5
+ # A read-only debugging view over a DOM scope (a document or any element),
6
+ # built on DomSummary. `Dommy::Rack::Session` and `Dommy::Browser` expose it
7
+ # as `#debug` (scoped to the current `within`), but it wraps a plain node so
8
+ # it works on any document too — e.g. `Dommy::Interaction::Debug.new(dom)`
9
+ # in a request/view spec.
10
+ #
11
+ # browser.debug.dom_summary # readable forms/links/buttons/fields
12
+ # browser.debug.buttons # structured [{label:, type:, selector:}]
13
+ class Debug
14
+ def initialize(scope)
15
+ @scope = scope
16
+ end
17
+
18
+ # A readable, sectioned summary of the visible controls.
19
+ def dom_summary = DomSummary.to_text(@scope)
20
+
21
+ def forms = DomSummary.forms(@scope)
22
+ def links = DomSummary.links(@scope)
23
+ def buttons = DomSummary.buttons(@scope)
24
+ def fields = DomSummary.fields(@scope)
25
+
26
+ # The scope's collapsed visible text content.
27
+ def visible_text = DomSummary.text(@scope)
28
+
29
+ # The accessibility tree / Playwright-compatible ARIA snapshot of the
30
+ # current scope.
31
+ def aria_tree = Internal::AccessibilityTree.build(@scope)
32
+ def aria_snapshot = Internal::AriaSnapshot.serialize(aria_tree)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Interaction
5
+ # Summarizes the user-facing controls of a DOM scope (a document or any
6
+ # element with `query_selector_all`): the buttons, fields, links, and forms
7
+ # a person would see. Engine-independent and scope-general — it backs the
8
+ # finder's "available …" failure messages, the `Debug` facade, and failure
9
+ # artifacts, and can be called directly on any document (e.g. a request
10
+ # spec's `dom`).
11
+ #
12
+ # Dommy::Interaction::DomSummary.buttons(document)
13
+ # #=> [{label: "Save", type: "submit", selector: "button[type=submit]"}]
14
+ # puts Dommy::Interaction::DomSummary.to_text(document)
15
+ module DomSummary
16
+ module_function
17
+
18
+ # Submit-capable and plain buttons, as {label:, type:, selector:}.
19
+ def buttons(scope)
20
+ return [] unless scope
21
+
22
+ scope.query_selector_all("button, input[type='submit'], input[type='image'], input[type='button'], input[type='reset']").map do |el|
23
+ {label: button_label(el), type: presence(el.get_attribute("type")), selector: selector_for(el)}
24
+ end
25
+ end
26
+
27
+ # Form fields, as {label:, name:, id:, type:, selector:}.
28
+ def fields(scope)
29
+ return [] unless scope
30
+
31
+ scope.query_selector_all("input, textarea, select").reject { |el| hidden_input?(el) }.map do |el|
32
+ {label: field_label(el, scope), name: presence(el.get_attribute("name")),
33
+ id: presence(el.get_attribute("id")), type: field_type(el), selector: selector_for(el)}
34
+ end
35
+ end
36
+
37
+ # Links with an href, as {text:, href:}.
38
+ def links(scope)
39
+ return [] unless scope
40
+
41
+ scope.query_selector_all("a[href]").map do |el|
42
+ {text: squish(el.text_content), href: el.get_attribute("href").to_s}
43
+ end
44
+ end
45
+
46
+ # Forms, as {action:, method:, fields: [names]}.
47
+ def forms(scope)
48
+ return [] unless scope
49
+
50
+ scope.query_selector_all("form").map do |form|
51
+ {action: presence(form.get_attribute("action")), method: form_method(form),
52
+ fields: fields(form).map { |f| f[:name] }.compact}
53
+ end
54
+ end
55
+
56
+ # One-line descriptors for failure messages: `"Save" button[type=submit]`.
57
+ def button_labels(scope) = buttons(scope).map { |b| "#{quote(b[:label])} #{b[:selector]}".strip }
58
+ def field_labels(scope) = fields(scope).map { |f| "#{quote(f[:label] || f[:name])} #{f[:selector]}".strip }
59
+ def link_labels(scope) = links(scope).map { |l| "#{quote(l[:text])} -> #{l[:href]}" }
60
+
61
+ # A readable, sectioned summary of the scope's controls (used by the
62
+ # `Debug` facade and failure artifacts).
63
+ def to_text(scope)
64
+ sections = {
65
+ "Forms" => forms(scope).map { |f| "#{(f[:method] || "get").upcase} #{f[:action] || "(self)"} [#{f[:fields].join(", ")}]" },
66
+ "Links" => link_labels(scope),
67
+ "Buttons" => button_labels(scope),
68
+ "Fields" => field_labels(scope)
69
+ }
70
+ sections.reject { |_, rows| rows.empty? }
71
+ .map { |name, rows| "#{name}:\n#{rows.map { |r| " #{r}" }.join("\n")}" }
72
+ .join("\n")
73
+ end
74
+
75
+ # --- label / descriptor helpers ---
76
+
77
+ def button_label(el)
78
+ if el.tag_name == "INPUT"
79
+ presence(el.get_attribute("value")) || presence(el.get_attribute("alt")) || ""
80
+ else
81
+ squish(el.text_content)
82
+ end
83
+ end
84
+
85
+ # Prefer an associated <label>, then aria-label, placeholder, name, id.
86
+ def field_label(el, scope)
87
+ label_caption(el, scope) || presence(el.get_attribute("aria-label")) ||
88
+ presence(el.get_attribute("placeholder")) || presence(el.get_attribute("name")) ||
89
+ presence(el.get_attribute("id"))
90
+ end
91
+
92
+ def label_caption(el, scope)
93
+ id = presence(el.get_attribute("id"))
94
+ wrapping = el.respond_to?(:closest) ? el.closest("label") : nil
95
+ label = (id && scope.query_selector_all("label").find { |l| l.get_attribute("for") == id }) || wrapping
96
+ label ? presence(squish(label.text_content)) : nil
97
+ end
98
+
99
+ def field_type(el)
100
+ return el.tag_name.downcase unless el.tag_name == "INPUT"
101
+
102
+ presence(el.get_attribute("type")) || "text"
103
+ end
104
+
105
+ def selector_for(el)
106
+ tag = el.tag_name.downcase
107
+ type = presence(el.get_attribute("type"))
108
+ name = presence(el.get_attribute("name"))
109
+ descriptor = tag
110
+ descriptor += "[type=#{type}]" if type
111
+ descriptor += "[name=#{name}]" if name
112
+ descriptor
113
+ end
114
+
115
+ # Collapsed visible text of a scope. A Document has no text_content of its
116
+ # own, so read its <body>.
117
+ def text(scope)
118
+ return "" unless scope
119
+
120
+ node = scope.respond_to?(:body) ? scope.body : scope
121
+ squish(node&.text_content)
122
+ end
123
+
124
+ def hidden_input?(el) = el.tag_name == "INPUT" && el.get_attribute("type") == "hidden"
125
+ def form_method(form) = presence(form.get_attribute("method"))&.downcase
126
+ def squish(text) = text.to_s.gsub(/\s+/, " ").strip
127
+ def presence(value) = (value.nil? || value.to_s.empty?) ? nil : value
128
+ def quote(value) = value.to_s.inspect
129
+ end
130
+ end
131
+ end