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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/html_elements.rb
CHANGED
|
@@ -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").
|
|
633
|
-
#
|
|
634
|
-
# `
|
|
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
|
|
824
|
+
return nil unless stylesheet_rel?
|
|
637
825
|
|
|
638
|
-
@__sheet ||=
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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.
|
|
1747
|
-
# `
|
|
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
|
-
|
|
1757
|
-
|
|
1980
|
+
end
|
|
1981
|
+
|
|
1982
|
+
def set_attribute(name, value)
|
|
1983
|
+
with_toggle_on_open_change { super }
|
|
1984
|
+
end
|
|
1758
1985
|
|
|
1759
|
-
|
|
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"] ||
|
|
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"]
|
|
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
|
|
2929
|
-
#
|
|
2930
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
3096
|
-
|
|
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
|
-
|
|
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
|