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
data/lib/dommy/event.rb CHANGED
@@ -6,12 +6,12 @@ module Dommy
6
6
  # surface.
7
7
 
8
8
  module EventTarget
9
- def add_event_listener(type, listener = nil, options = nil, &block)
9
+ def add_event_listener(type, listener = nil, options = nil, event_handler: false, &block)
10
10
  cb = listener || block
11
11
  return nil if type.nil? || cb.nil?
12
12
 
13
13
  list = listeners_for(type.to_s)
14
- entry = Listener.new(cb, options)
14
+ entry = Listener.new(cb, options, event_handler)
15
15
  # Per spec, a listener is deduplicated by (type, callback, capture) — so
16
16
  # the same function may be registered once as a capture and once as a
17
17
  # bubble listener.
@@ -56,6 +56,29 @@ module Dommy
56
56
  nil
57
57
  end
58
58
 
59
+ # Event handler IDL attributes (`el.onclick = fn`, `window.onload = fn`): one
60
+ # named handler registered as a listener, where assignment replaces any
61
+ # previous handler and a nil value removes it. Shared by Element and Window.
62
+ def event_name_from_on(key)
63
+ key.to_s.sub(/\Aon/, "").downcase
64
+ end
65
+
66
+ def on_handler(event_name)
67
+ @on_handlers&.[](event_name)
68
+ end
69
+
70
+ def set_on_handler(event_name, value)
71
+ @on_handlers ||= {}
72
+ previous = @on_handlers[event_name]
73
+ remove_event_listener(event_name, previous) if previous
74
+ if value
75
+ add_event_listener(event_name, value, event_handler: true)
76
+ @on_handlers[event_name] = value
77
+ else
78
+ @on_handlers.delete(event_name)
79
+ end
80
+ end
81
+
59
82
  def dispatch_event(event)
60
83
  return true if event.nil?
61
84
 
@@ -130,11 +153,16 @@ module Dommy
130
153
  end
131
154
  end
132
155
 
133
- if entry.passive?
134
- event.__internal_run_passive__ { invoke_listener_isolated(entry.listener, event) }
135
- else
136
- invoke_listener_isolated(entry.listener, event)
137
- end
156
+ result =
157
+ if entry.passive?
158
+ event.__internal_run_passive__ { invoke_listener_isolated(entry.listener, event, self) }
159
+ else
160
+ invoke_listener_isolated(entry.listener, event, self)
161
+ end
162
+ # Event handler processing algorithm: a handler registered via onX has its
163
+ # return value processed — onerror on a global cancels on `true`, every
164
+ # other handler cancels on `false` (`onsubmit="return false"`).
165
+ __internal_process_event_handler_return__(event, result) if entry.event_handler?
138
166
 
139
167
  break if event.immediate_propagation_stopped?
140
168
  end
@@ -142,6 +170,19 @@ module Dommy
142
170
  nil
143
171
  end
144
172
 
173
+ # WHATWG event handler processing algorithm, step "process return value":
174
+ # the special error handler (onerror on a Window) cancels the event when the
175
+ # handler returns true; onbeforeunload has its own returnValue handling
176
+ # (left alone); any other handler cancels when it returns exactly false
177
+ # (`return false` from onclick/onsubmit). A non-cancelable event ignores it.
178
+ def __internal_process_event_handler_return__(event, result)
179
+ if event.type == "error" && defined?(Dommy::Window) && is_a?(Dommy::Window)
180
+ event.__js_call__("preventDefault", []) if result == true
181
+ elsif event.type != "beforeunload" && result == false
182
+ event.__js_call__("preventDefault", [])
183
+ end
184
+ end
185
+
145
186
  # Run one listener, isolating a throw so it can't escape the dispatch.
146
187
  # WHATWG "inner invoke": if a listener's callback throws, the exception is
147
188
  # *reported* (to the global error handler) and event dispatch continues with
@@ -152,8 +193,8 @@ module Dommy
152
193
  # engine/Ruby-version-dependent — on some QuickJS/Ruby combinations a JS
153
194
  # listener's throw surfaces as a Ruby exception here — so guard the
154
195
  # host-initiated path too, mirroring MutationObserver#flush.
155
- def invoke_listener_isolated(listener, event)
156
- CallableInvoker.invoke_listener(listener, event)
196
+ def invoke_listener_isolated(listener, event, current_target = nil)
197
+ CallableInvoker.invoke_listener(listener, event, current_target)
157
198
  rescue StandardError => e
158
199
  __dommy_dump_event_failure__(event, listener, e) if ENV["DOMMY_EVENT_DEBUG"]
159
200
  nil
@@ -181,7 +222,12 @@ module Dommy
181
222
 
182
223
  private
183
224
 
184
- Listener = Struct.new(:listener, :options) do
225
+ Listener = Struct.new(:listener, :options, :event_handler) do
226
+ # True when this listener was registered via an event handler IDL/content
227
+ # attribute (el.onclick = fn / onclick="…"): its return value is processed
228
+ # per the event handler processing algorithm (a false return cancels).
229
+ def event_handler? = event_handler ? true : false
230
+
185
231
  def once?
186
232
  case options
187
233
  when Hash
@@ -489,7 +535,9 @@ module Dommy
489
535
  @immediate_propagation_stopped = true
490
536
  nil
491
537
  when "composedPath"
492
- @composed_path.dup
538
+ # composedPath() is the event path only while the event is being
539
+ # dispatched; once dispatch finishes (currentTarget is null) it is empty.
540
+ @dispatch_flag ? @composed_path.dup : []
493
541
  when "initEvent"
494
542
  # WebIDL: the `type` argument is mandatory.
495
543
  raise Bridge::TypeError, "initEvent requires a type argument" if args.empty?
@@ -584,6 +632,71 @@ module Dommy
584
632
  end
585
633
  end
586
634
 
635
+ # UIEvent — the base for user-interface events (MouseEvent, KeyboardEvent,
636
+ # CompositionEvent, …), adding `view` (the associated window) and `detail`.
637
+ # Carries the legacy `initUIEvent`, which — like initEvent — is a no-op while
638
+ # the event is being dispatched.
639
+ class UIEvent < Event
640
+ def initialize(type, init = nil)
641
+ super
642
+ @view = read_init(init, "view")
643
+ # WebIDL: `view` is `Window?`, so a present non-null value that isn't a
644
+ # Window is a TypeError during dictionary conversion.
645
+ unless @view.nil? || (defined?(Bridge::UNDEFINED) && @view.equal?(Bridge::UNDEFINED)) ||
646
+ @view.is_a?(Dommy::Window)
647
+ raise Bridge::TypeError, "'view' member must be a Window or null"
648
+ end
649
+ @view = nil if defined?(Bridge::UNDEFINED) && @view.equal?(Bridge::UNDEFINED)
650
+ @detail = (read_init(init, "detail") || 0).to_i
651
+ end
652
+
653
+ def __js_get__(key)
654
+ case key
655
+ when "view" then @view
656
+ when "detail" then @detail
657
+ else super
658
+ end
659
+ end
660
+
661
+ js_methods %w[initUIEvent]
662
+ def __js_call__(method, args)
663
+ case method
664
+ when "initUIEvent"
665
+ # Deprecated initUIEvent(type, bubbles=false, cancelable=false, view=null,
666
+ # detail=0). Mandatory type; a no-op while dispatching.
667
+ raise Bridge::TypeError, "initUIEvent requires a type argument" if args.empty?
668
+
669
+ unless @dispatch_flag
670
+ init_event(args[0], args[1], args[2])
671
+ @view = args[3]
672
+ @detail = (args[4] || 0).to_i
673
+ end
674
+ nil
675
+ else
676
+ super
677
+ end
678
+ end
679
+ end
680
+
681
+ # ToggleEvent — fired at a `<details>` (and other poppable elements) when it
682
+ # opens/closes, exposing the transition via `oldState` / `newState`
683
+ # ("open"/"closed"). A plain Event subclass.
684
+ class ToggleEvent < Event
685
+ def initialize(type, init = nil)
686
+ super
687
+ @old_state = read_init(init, "oldState").to_s
688
+ @new_state = read_init(init, "newState").to_s
689
+ end
690
+
691
+ def __js_get__(key)
692
+ case key
693
+ when "oldState" then @old_state
694
+ when "newState" then @new_state
695
+ else super
696
+ end
697
+ end
698
+ end
699
+
587
700
  # PopStateEvent — fired on history back/forward. Exposes the history entry's
588
701
  # serialized state via `event.state` (the spec property; framework routers like
589
702
  # Turbo read `event.state.turbo` to recognise their own navigations and restore
@@ -604,6 +717,47 @@ module Dommy
604
717
  end
605
718
  end
606
719
 
720
+ # HashChangeEvent — fired on the window when the URL fragment changes (a
721
+ # fragment-navigation link, `location.hash = ...`). Exposes the full document
722
+ # URL before and after the change as `oldURL` / `newURL` (spec USVStrings). A
723
+ # plain `Event` subclass, NOT a CustomEvent.
724
+ class HashChangeEvent < Event
725
+ attr_reader :old_url, :new_url
726
+
727
+ def initialize(type, init = nil)
728
+ super
729
+ @old_url = read_init(init, "oldURL").to_s
730
+ @new_url = read_init(init, "newURL").to_s
731
+ end
732
+
733
+ def __js_get__(key)
734
+ case key
735
+ when "oldURL" then @old_url
736
+ when "newURL" then @new_url
737
+ else super
738
+ end
739
+ end
740
+ end
741
+
742
+ # SubmitEvent — fired on a form at submission. Exposes the button/input that
743
+ # triggered submission via `submitter` (null for `form.requestSubmit()` with
744
+ # no argument or an implicit submission). Frameworks (Turbo) read
745
+ # `event.submitter` to locate the clicked control.
746
+ class SubmitEvent < Event
747
+ attr_reader :submitter
748
+
749
+ def initialize(type, init = nil)
750
+ super
751
+ @submitter = read_init(init, "submitter")
752
+ end
753
+
754
+ def __js_get__(key)
755
+ return @submitter if key == "submitter"
756
+
757
+ super
758
+ end
759
+ end
760
+
607
761
  # `ErrorEvent` — the event interface for an uncaught error (fired at
608
762
  # `window.onerror`). Error-reporting code constructs it directly
609
763
  # (`new ErrorEvent("error", { message, error, … })`); without the constructor
@@ -658,7 +812,7 @@ module Dommy
658
812
  end
659
813
  end
660
814
 
661
- class MouseEvent < Event
815
+ class MouseEvent < UIEvent
662
816
  def initialize(type, init = nil)
663
817
  super
664
818
  @button = read_init(init, "button") || 0
@@ -666,6 +820,10 @@ module Dommy
666
820
  @shift_key = !!read_init(init, "shiftKey")
667
821
  @alt_key = !!read_init(init, "altKey")
668
822
  @meta_key = !!read_init(init, "metaKey")
823
+ @buttons = read_init(init, "buttons") || 0
824
+ @related_target = read_init(init, "relatedTarget")
825
+ @screen_x = read_init(init, "screenX") || 0
826
+ @screen_y = read_init(init, "screenY") || 0
669
827
  @client_x = read_init(init, "clientX") || 0
670
828
  @client_y = read_init(init, "clientY") || 0
671
829
  end
@@ -682,6 +840,14 @@ module Dommy
682
840
  @alt_key
683
841
  when "metaKey"
684
842
  @meta_key
843
+ when "buttons"
844
+ @buttons
845
+ when "relatedTarget"
846
+ @related_target
847
+ when "screenX"
848
+ @screen_x
849
+ when "screenY"
850
+ @screen_y
685
851
  when "clientX"
686
852
  @client_x
687
853
  when "clientY"
@@ -690,6 +856,35 @@ module Dommy
690
856
  super
691
857
  end
692
858
  end
859
+
860
+ js_methods %w[initMouseEvent]
861
+ def __js_call__(method, args)
862
+ case method
863
+ when "initMouseEvent"
864
+ # Legacy initMouseEvent(type, bubbles, cancelable, view, detail, screenX,
865
+ # screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey, button,
866
+ # relatedTarget). A no-op while dispatching.
867
+ raise Bridge::TypeError, "initMouseEvent requires a type argument" if args.empty?
868
+
869
+ unless @dispatch_flag
870
+ init_event(args[0], args[1], args[2])
871
+ @view = args[3]
872
+ @detail = (args[4] || 0).to_i
873
+ @screen_x = (args[5] || 0).to_i
874
+ @screen_y = (args[6] || 0).to_i
875
+ @client_x = (args[7] || 0).to_i
876
+ @client_y = (args[8] || 0).to_i
877
+ @ctrl_key = !!args[9]
878
+ @alt_key = !!args[10]
879
+ @shift_key = !!args[11]
880
+ @meta_key = !!args[12]
881
+ @button = (args[13] || 0).to_i
882
+ end
883
+ nil
884
+ else
885
+ super
886
+ end
887
+ end
693
888
  end
694
889
 
695
890
  # `DragEvent` — fired during drag-and-drop with a `dataTransfer`
@@ -713,20 +908,60 @@ module Dommy
713
908
  end
714
909
  end
715
910
 
716
- class KeyboardEvent < Event
911
+ class KeyboardEvent < UIEvent
912
+ js_methods %w[getModifierState initKeyboardEvent]
913
+
914
+ # Legacy keyCode values for non-printable keys (UI Events "keyCode"
915
+ # informative mapping) — jQuery-era code still branches on these.
916
+ LEGACY_KEY_CODES = {
917
+ "Backspace" => 8, "Tab" => 9, "Enter" => 13, "Shift" => 16,
918
+ "Control" => 17, "Alt" => 18, "CapsLock" => 20, "Escape" => 27,
919
+ " " => 32, "PageUp" => 33, "PageDown" => 34, "End" => 35, "Home" => 36,
920
+ "ArrowLeft" => 37, "ArrowUp" => 38, "ArrowRight" => 39, "ArrowDown" => 40,
921
+ "Delete" => 46, "Meta" => 91,
922
+ }.freeze
923
+
717
924
  def initialize(type, init = nil)
718
925
  super
719
926
  @key = read_init(init, "key").to_s
927
+ @code = read_init(init, "code").to_s
720
928
  @ctrl_key = !!read_init(init, "ctrlKey")
721
929
  @shift_key = !!read_init(init, "shiftKey")
722
930
  @alt_key = !!read_init(init, "altKey")
723
931
  @meta_key = !!read_init(init, "metaKey")
932
+ @repeat = !!read_init(init, "repeat")
933
+ @location = (read_init(init, "location") || 0).to_i
934
+ @is_composing = !!read_init(init, "isComposing")
935
+ @key_code = read_init(init, "keyCode")
936
+ @char_code = read_init(init, "charCode")
937
+ end
938
+
939
+ # Legacy keyCode: explicit init wins; a printable keypress reports the
940
+ # character code (Chrome parity), a printable keydown/keyup the upcased
941
+ # character; named keys use the legacy table.
942
+ def key_code
943
+ return @key_code.to_i if @key_code
944
+
945
+ if printable_key?
946
+ type == "keypress" ? @key.ord : @key.upcase(:ascii).ord
947
+ else
948
+ LEGACY_KEY_CODES[@key] || 0
949
+ end
950
+ end
951
+
952
+ # Legacy charCode: nonzero only on a printable keypress.
953
+ def char_code
954
+ return @char_code.to_i if @char_code
955
+
956
+ type == "keypress" && printable_key? ? @key.ord : 0
724
957
  end
725
958
 
726
959
  def __js_get__(key)
727
960
  case key
728
961
  when "key"
729
962
  @key
963
+ when "code"
964
+ @code
730
965
  when "ctrlKey"
731
966
  @ctrl_key
732
967
  when "shiftKey"
@@ -735,10 +970,56 @@ module Dommy
735
970
  @alt_key
736
971
  when "metaKey"
737
972
  @meta_key
973
+ when "repeat"
974
+ @repeat
975
+ when "location"
976
+ @location
977
+ when "isComposing"
978
+ @is_composing
979
+ when "keyCode"
980
+ key_code
981
+ when "charCode"
982
+ char_code
983
+ when "which"
984
+ key_code.zero? ? char_code : key_code
985
+ else
986
+ super
987
+ end
988
+ end
989
+
990
+ def __js_call__(method, args)
991
+ case method
992
+ when "getModifierState"
993
+ case (args || []).first.to_s
994
+ when "Shift" then @shift_key
995
+ when "Control" then @ctrl_key
996
+ when "Alt" then @alt_key
997
+ when "Meta" then @meta_key
998
+ else false
999
+ end
1000
+ when "initKeyboardEvent"
1001
+ # Legacy initKeyboardEvent(type, bubbles, cancelable, view, key, location,
1002
+ # modifiersList, repeat, locale). A no-op while dispatching.
1003
+ raise Bridge::TypeError, "initKeyboardEvent requires a type argument" if args.empty?
1004
+
1005
+ unless @dispatch_flag
1006
+ init_event(args[0], args[1], args[2])
1007
+ @view = args[3]
1008
+ @key = args[4].to_s
1009
+ @location = (args[5] || 0).to_i
1010
+ @repeat = !!args[7]
1011
+ end
1012
+ nil
738
1013
  else
739
1014
  super
740
1015
  end
741
1016
  end
1017
+
1018
+ private
1019
+
1020
+ def printable_key?
1021
+ @key.length == 1
1022
+ end
742
1023
  end
743
1024
 
744
1025
  # `InputEvent` — fired by `input` / `beforeinput`. Carries `data`
@@ -1012,7 +1293,7 @@ module Dommy
1012
1293
 
1013
1294
  # `CompositionEvent` — IME composition events (compositionstart /
1014
1295
  # compositionupdate / compositionend). `data` holds the composing text.
1015
- class CompositionEvent < Event
1296
+ class CompositionEvent < UIEvent
1016
1297
  def initialize(type, init = nil)
1017
1298
  super
1018
1299
  @data = (read_init(init, "data") || "").to_s
@@ -1065,7 +1346,7 @@ module Dommy
1065
1346
 
1066
1347
  # `FocusEvent` — focus / blur / focusin / focusout. `relatedTarget`
1067
1348
  # is the element gaining/losing focus on the other side.
1068
- class FocusEvent < Event
1349
+ class FocusEvent < UIEvent
1069
1350
  def initialize(type, init = nil)
1070
1351
  super
1071
1352
  @related_target = read_init(init, "relatedTarget")