dommy 0.6.0 → 0.7.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +1 -1
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +4 -4
  10. data/lib/dommy/cookie_store.rb +1 -1
  11. data/lib/dommy/crypto.rb +9 -8
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +98 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +231 -50
  17. data/lib/dommy/event.rb +61 -25
  18. data/lib/dommy/event_source.rb +8 -8
  19. data/lib/dommy/fetch.rb +14 -6
  20. data/lib/dommy/file_reader.rb +3 -3
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +7 -4
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +110 -42
  25. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  26. data/lib/dommy/internal/dom_matching.rb +3 -3
  27. data/lib/dommy/internal/node_traversal.rb +1 -1
  28. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  29. data/lib/dommy/internal/template_content_registry.rb +6 -6
  30. data/lib/dommy/intersection_observer.rb +2 -2
  31. data/lib/dommy/location.rb +8 -4
  32. data/lib/dommy/media_query_list.rb +3 -3
  33. data/lib/dommy/message_channel.rb +9 -9
  34. data/lib/dommy/mutation_observer.rb +21 -11
  35. data/lib/dommy/navigator.rb +12 -12
  36. data/lib/dommy/node.rb +12 -0
  37. data/lib/dommy/notification.rb +3 -3
  38. data/lib/dommy/parser.rb +13 -13
  39. data/lib/dommy/performance_observer.rb +2 -2
  40. data/lib/dommy/range.rb +2 -2
  41. data/lib/dommy/resize_observer.rb +2 -2
  42. data/lib/dommy/shadow_root.rb +10 -8
  43. data/lib/dommy/streams.rb +22 -22
  44. data/lib/dommy/text_codec.rb +4 -4
  45. data/lib/dommy/tree_walker.rb +21 -21
  46. data/lib/dommy/url.rb +25 -8
  47. data/lib/dommy/version.rb +1 -1
  48. data/lib/dommy/web_socket.rb +13 -13
  49. data/lib/dommy/window.rb +14 -1
  50. data/lib/dommy/worker.rb +5 -5
  51. data/lib/dommy/xml_http_request.rb +19 -4
  52. data/lib/dommy.rb +12 -2
  53. metadata +12 -26
@@ -154,6 +154,12 @@ module Dommy
154
154
  # `<form>` — element collection, submit/reset, and a stubbed
155
155
  # validation surface.
156
156
  class HTMLFormElement < HTMLElement
157
+ # Own __js_call__ methods, on top of Element's.
158
+ JS_METHOD_NAMES = %w[submit reset requestSubmit checkValidity reportValidity].freeze
159
+ def __js_method_names__
160
+ super + JS_METHOD_NAMES
161
+ end
162
+
157
163
  def name
158
164
  reflected_string("name")
159
165
  end
@@ -211,7 +217,7 @@ module Dommy
211
217
  el = self
212
218
  HTMLCollection.new do
213
219
  el
214
- .__node__
220
+ .__dommy_backend_node__
215
221
  .css("input, select, textarea, button, output, fieldset")
216
222
  .map do |n|
217
223
  el.document.wrap_node(n)
@@ -245,7 +251,7 @@ module Dommy
245
251
  # `submitter` (if given) must be a button inside this form.
246
252
  def request_submit(submitter = nil)
247
253
  if submitter
248
- unless submitter.respond_to?(:__node__) && submitter.__node__.ancestors.include?(@__node__)
254
+ unless submitter.respond_to?(:__dommy_backend_node__) && submitter.__dommy_backend_node__.ancestors.include?(@__node__)
249
255
  raise DOMException::NotFoundError, "submitter is not a descendant of this form"
250
256
  end
251
257
 
@@ -346,6 +352,15 @@ module Dommy
346
352
 
347
353
  # `<input>` — covers the most-used form control surface.
348
354
  class HTMLInputElement < HTMLElement
355
+ # Own __js_call__ methods, on top of Element's.
356
+ JS_METHOD_NAMES = %w[
357
+ select setSelectionRange setRangeText stepUp stepDown
358
+ checkValidity reportValidity setCustomValidity
359
+ ].freeze
360
+ def __js_method_names__
361
+ super + JS_METHOD_NAMES
362
+ end
363
+
349
364
  def type
350
365
  raw = @__node__["type"].to_s
351
366
  raw.empty? ? "text" : raw.downcase
@@ -422,14 +437,14 @@ module Dommy
422
437
  end
423
438
 
424
439
  # `files` — for `<input type="file">`. Browsers populate this via
425
- # user interaction; in tests, code uses `__set_files__` to seed it.
440
+ # user interaction; in tests, code uses `__driver_set_files__` to seed it.
426
441
  def files
427
442
  @__files ||= FileList.new
428
443
  end
429
444
 
430
445
  # Test-only seam: set the input's file list directly.
431
446
  # Accepts an array (wrapped in a FileList) or a FileList itself.
432
- def __set_files__(files_input)
447
+ def __driver_set_files__(files_input)
433
448
  @__files = files_input.is_a?(FileList) ? files_input : FileList.new(Array(files_input))
434
449
  end
435
450
 
@@ -1381,13 +1396,13 @@ module Dommy
1381
1396
  def host_attr_value(name)
1382
1397
  return "" unless @host
1383
1398
 
1384
- @host.__node__[name].to_s
1399
+ @host.__dommy_backend_node__[name].to_s
1385
1400
  end
1386
1401
 
1387
1402
  def host_attr_present?(name)
1388
1403
  return false unless @host
1389
1404
 
1390
- @host.__node__.key?(name.to_s)
1405
+ @host.__dommy_backend_node__.key?(name.to_s)
1391
1406
  end
1392
1407
 
1393
1408
  def host_type
@@ -1482,7 +1497,7 @@ module Dommy
1482
1497
  sel = closest("select")
1483
1498
  return 0 unless sel
1484
1499
 
1485
- sel.options.find_index { |o| o.__node__ == @__node__ } || 0
1500
+ sel.options.find_index { |o| o.__dommy_backend_node__ == @__node__ } || 0
1486
1501
  end
1487
1502
 
1488
1503
  def __js_get__(key)
@@ -1569,6 +1584,14 @@ module Dommy
1569
1584
 
1570
1585
  # `<textarea>` — multi-line text input.
1571
1586
  class HTMLTextAreaElement < HTMLElement
1587
+ # Own __js_call__ methods, on top of Element's.
1588
+ JS_METHOD_NAMES = %w[
1589
+ select setSelectionRange setRangeText checkValidity reportValidity setCustomValidity
1590
+ ].freeze
1591
+ def __js_method_names__
1592
+ super + JS_METHOD_NAMES
1593
+ end
1594
+
1572
1595
  def value
1573
1596
  @__node__["value"] || text_content
1574
1597
  end
@@ -1865,7 +1888,7 @@ module Dommy
1865
1888
  el = self
1866
1889
  HTMLCollection.new do
1867
1890
  el
1868
- .__node__
1891
+ .__dommy_backend_node__
1869
1892
  .css("input, select, textarea, button, output, fieldset")
1870
1893
  .map do |n|
1871
1894
  el.document.wrap_node(n)
@@ -2031,6 +2054,12 @@ module Dommy
2031
2054
  # `slot` attribute go to the unnamed default slot. If nothing is
2032
2055
  # assigned, the slot's own children render as fallback content.
2033
2056
  class HTMLSlotElement < HTMLElement
2057
+ # Own __js_call__ methods, on top of Element's.
2058
+ JS_METHOD_NAMES = %w[assignedNodes assignedElements assign].freeze
2059
+ def __js_method_names__
2060
+ super + JS_METHOD_NAMES
2061
+ end
2062
+
2034
2063
  def name
2035
2064
  reflected_string("name")
2036
2065
  end
@@ -2062,7 +2091,7 @@ module Dommy
2062
2091
  # call and fire `slotchange` in both modes; named mode simply
2063
2092
  # ignores the override.
2064
2093
  def assign(*nodes)
2065
- @__manual_assignment = nodes.flatten.select { |n| n.respond_to?(:__node__) }
2094
+ @__manual_assignment = nodes.flatten.select { |n| n.respond_to?(:__dommy_backend_node__) }
2066
2095
  dispatch_event(Event.new("slotchange", "bubbles" => true))
2067
2096
  nil
2068
2097
  end
@@ -2105,7 +2134,7 @@ module Dommy
2105
2134
  private
2106
2135
 
2107
2136
  def matching_light_nodes
2108
- sr = @document.__shadow_root_containing__(@__node__)
2137
+ sr = @document.__internal_shadow_root_containing__(@__node__)
2109
2138
  return [] unless sr
2110
2139
 
2111
2140
  host = sr.host
@@ -2118,7 +2147,7 @@ module Dommy
2118
2147
  end
2119
2148
 
2120
2149
  host
2121
- .__node__
2150
+ .__dommy_backend_node__
2122
2151
  .children
2123
2152
  .map do |child|
2124
2153
  wrapped = @document.wrap_node(child)
@@ -2139,6 +2168,12 @@ module Dommy
2139
2168
  # `selectedIndex`, and dispatches change events. Minimal compared to
2140
2169
  # happy-dom's full HTMLSelectElement, but covers common test cases.
2141
2170
  class HTMLSelectElement < HTMLElement
2171
+ # Own __js_call__ methods, on top of Element's.
2172
+ JS_METHOD_NAMES = %w[item add checkValidity reportValidity setCustomValidity].freeze
2173
+ def __js_method_names__
2174
+ super + JS_METHOD_NAMES
2175
+ end
2176
+
2142
2177
  def name
2143
2178
  reflected_string("name")
2144
2179
  end
@@ -2165,7 +2200,7 @@ module Dommy
2165
2200
  def options
2166
2201
  el = self
2167
2202
  HTMLOptionsCollection.new(self) do
2168
- el.__node__.css("option").map { |n| el.document.wrap_node(n) }.compact
2203
+ el.__dommy_backend_node__.css("option").map { |n| el.document.wrap_node(n) }.compact
2169
2204
  end
2170
2205
  end
2171
2206
 
@@ -2175,8 +2210,8 @@ module Dommy
2175
2210
  def selected_options
2176
2211
  el = self
2177
2212
  HTMLCollection.new do
2178
- opts = el.__node__.css("option").map { |n| el.document.wrap_node(n) }.compact
2179
- chosen = opts.select { |o| o.__node__.key?("selected") }
2213
+ opts = el.__dommy_backend_node__.css("option").map { |n| el.document.wrap_node(n) }.compact
2214
+ chosen = opts.select { |o| o.__dommy_backend_node__.key?("selected") }
2180
2215
  next chosen unless chosen.empty?
2181
2216
  next [] if el.multiple
2182
2217
 
@@ -2196,7 +2231,7 @@ module Dommy
2196
2231
  # not multiple, or -1 if multiple and none.
2197
2232
  def selected_index
2198
2233
  opts = options
2199
- idx = opts.find_index { |o| o.__node__.key?("selected") }
2234
+ idx = opts.find_index { |o| o.__dommy_backend_node__.key?("selected") }
2200
2235
  return idx if idx
2201
2236
 
2202
2237
  multiple ? -1 : (opts.empty? ? -1 : 0)
@@ -2207,7 +2242,7 @@ module Dommy
2207
2242
  opts.each_with_index do |o, idx|
2208
2243
  if idx == i.to_i
2209
2244
  o.set_attribute("selected", "")
2210
- elsif o.__node__.key?("selected")
2245
+ elsif o.__dommy_backend_node__.key?("selected")
2211
2246
  o.remove_attribute("selected")
2212
2247
  end
2213
2248
  end
@@ -2216,15 +2251,15 @@ module Dommy
2216
2251
  # `value` of the select = value of the selected option, or "".
2217
2252
  def value
2218
2253
  opts = options
2219
- sel = opts.find { |o| o.__node__.key?("selected") } || opts.first
2220
- sel ? (sel.__node__["value"] || sel.text_content).to_s : ""
2254
+ sel = opts.find { |o| o.__dommy_backend_node__.key?("selected") } || opts.first
2255
+ sel ? (sel.__dommy_backend_node__["value"] || sel.text_content).to_s : ""
2221
2256
  end
2222
2257
 
2223
2258
  def value=(new_value)
2224
- target = options.find { |o| (o.__node__["value"] || o.text_content).to_s == new_value.to_s }
2259
+ target = options.find { |o| (o.__dommy_backend_node__["value"] || o.text_content).to_s == new_value.to_s }
2225
2260
  return unless target
2226
2261
 
2227
- options.each { |o| o.remove_attribute("selected") if o.__node__.key?("selected") }
2262
+ options.each { |o| o.remove_attribute("selected") if o.__dommy_backend_node__.key?("selected") }
2228
2263
  target.set_attribute("selected", "")
2229
2264
  end
2230
2265
 
@@ -2235,9 +2270,9 @@ module Dommy
2235
2270
 
2236
2271
  # `select.add(option, before)` — appends or inserts before `before`.
2237
2272
  def add(option, before = nil)
2238
- return nil unless option.respond_to?(:__node__)
2273
+ return nil unless option.respond_to?(:__dommy_backend_node__)
2239
2274
 
2240
- if before.respond_to?(:__node__)
2275
+ if before.respond_to?(:__dommy_backend_node__)
2241
2276
  insert_before(option, before)
2242
2277
  else
2243
2278
  append_child(option)
@@ -2367,6 +2402,12 @@ module Dommy
2367
2402
  # `close(returnValue?)`. Dommy has no modal stack, so showModal is
2368
2403
  # functionally identical to show (no backdrop, no escape-to-close).
2369
2404
  class HTMLDialogElement < HTMLElement
2405
+ # Own __js_call__ methods, on top of Element's.
2406
+ JS_METHOD_NAMES = %w[show showModal close].freeze
2407
+ def __js_method_names__
2408
+ super + JS_METHOD_NAMES
2409
+ end
2410
+
2370
2411
  def open
2371
2412
  reflected_boolean("open")
2372
2413
  end
@@ -2652,7 +2693,7 @@ module Dommy
2652
2693
  row = closest("tr")
2653
2694
  return -1 unless row
2654
2695
 
2655
- row.cells.find_index { |c| c.__node__ == @__node__ } || -1
2696
+ row.cells.find_index { |c| c.__dommy_backend_node__ == @__node__ } || -1
2656
2697
  end
2657
2698
 
2658
2699
  def col_span
@@ -2738,11 +2779,17 @@ module Dommy
2738
2779
  # `rowIndex` walks the enclosing table; `sectionRowIndex` walks
2739
2780
  # the enclosing thead/tbody/tfoot.
2740
2781
  class HTMLTableRowElement < HTMLElement
2782
+ # Own __js_call__ methods, on top of Element's.
2783
+ JS_METHOD_NAMES = %w[insertCell deleteCell].freeze
2784
+ def __js_method_names__
2785
+ super + JS_METHOD_NAMES
2786
+ end
2787
+
2741
2788
  def cells
2742
2789
  el = self
2743
2790
  HTMLCollection.new do
2744
2791
  el
2745
- .__node__
2792
+ .__dommy_backend_node__
2746
2793
  .element_children
2747
2794
  .select { |n| %w[td th].include?(n.name) }
2748
2795
  .map { |n| el.document.wrap_node(n) }
@@ -2754,7 +2801,7 @@ module Dommy
2754
2801
  table = closest("table")
2755
2802
  return -1 unless table
2756
2803
 
2757
- table.rows.find_index { |r| r.__node__ == @__node__ } || -1
2804
+ table.rows.find_index { |r| r.__dommy_backend_node__ == @__node__ } || -1
2758
2805
  end
2759
2806
 
2760
2807
  def section_row_index
@@ -2812,11 +2859,17 @@ module Dommy
2812
2859
  # `<thead>` / `<tbody>` / `<tfoot>` — share section-level row
2813
2860
  # collection + insertRow / deleteRow.
2814
2861
  class HTMLTableSectionElement < HTMLElement
2862
+ # Own __js_call__ methods, on top of Element's.
2863
+ JS_METHOD_NAMES = %w[insertRow deleteRow].freeze
2864
+ def __js_method_names__
2865
+ super + JS_METHOD_NAMES
2866
+ end
2867
+
2815
2868
  def rows
2816
2869
  el = self
2817
2870
  HTMLCollection.new do
2818
2871
  el
2819
- .__node__
2872
+ .__dommy_backend_node__
2820
2873
  .element_children
2821
2874
  .select { |n| n.name == "tr" }
2822
2875
  .map { |n| el.document.wrap_node(n) }
@@ -2866,16 +2919,25 @@ module Dommy
2866
2919
  # tbody elements. `insertRow(-1)` appends to the last tbody (or
2867
2920
  # creates one); `deleteRow` works against the merged `rows` list.
2868
2921
  class HTMLTableElement < HTMLElement
2922
+ # Own __js_call__ methods, on top of Element's.
2923
+ JS_METHOD_NAMES = %w[
2924
+ insertRow deleteRow createCaption deleteCaption createTHead deleteTHead
2925
+ createTFoot deleteTFoot createTBody
2926
+ ].freeze
2927
+ def __js_method_names__
2928
+ super + JS_METHOD_NAMES
2929
+ end
2930
+
2869
2931
  def caption
2870
2932
  @__node__.element_children.find { |n| n.name == "caption" }&.then { |n| @document.wrap_node(n) }
2871
2933
  end
2872
2934
 
2873
2935
  def caption=(new_caption)
2874
2936
  delete_caption
2875
- return unless new_caption.respond_to?(:__node__)
2937
+ return unless new_caption.respond_to?(:__dommy_backend_node__)
2876
2938
 
2877
2939
  first = @__node__.children.first
2878
- first ? first.add_previous_sibling(new_caption.__node__) : @__node__.add_child(new_caption.__node__)
2940
+ first ? first.add_previous_sibling(new_caption.__dommy_backend_node__) : @__node__.add_child(new_caption.__dommy_backend_node__)
2879
2941
  end
2880
2942
 
2881
2943
  def t_head
@@ -2890,7 +2952,7 @@ module Dommy
2890
2952
  el = self
2891
2953
  HTMLCollection.new do
2892
2954
  el
2893
- .__node__
2955
+ .__dommy_backend_node__
2894
2956
  .element_children
2895
2957
  .select { |n| n.name == "tbody" }
2896
2958
  .map { |n| el.document.wrap_node(n) }
@@ -2902,10 +2964,10 @@ module Dommy
2902
2964
  el = self
2903
2965
  HTMLCollection.new do
2904
2966
  ordered = []
2905
- head = el.__node__.element_children.find { |n| n.name == "thead" }
2906
- bodies = el.__node__.element_children.select { |n| n.name == "tbody" }
2907
- direct = el.__node__.element_children.select { |n| n.name == "tr" }
2908
- foot = el.__node__.element_children.find { |n| n.name == "tfoot" }
2967
+ head = el.__dommy_backend_node__.element_children.find { |n| n.name == "thead" }
2968
+ bodies = el.__dommy_backend_node__.element_children.select { |n| n.name == "tbody" }
2969
+ direct = el.__dommy_backend_node__.element_children.select { |n| n.name == "tr" }
2970
+ foot = el.__dommy_backend_node__.element_children.find { |n| n.name == "tfoot" }
2909
2971
  [head, *bodies, foot].compact.each do |sec|
2910
2972
  sec.element_children.select { |n| n.name == "tr" }.each { |n| ordered << n }
2911
2973
  end
@@ -2921,7 +2983,7 @@ module Dommy
2921
2983
 
2922
2984
  cap = @document.create_element("caption")
2923
2985
  first = @__node__.children.first
2924
- first ? first.add_previous_sibling(cap.__node__) : @__node__.add_child(cap.__node__)
2986
+ first ? first.add_previous_sibling(cap.__dommy_backend_node__) : @__node__.add_child(cap.__dommy_backend_node__)
2925
2987
  cap
2926
2988
  end
2927
2989
 
@@ -2938,10 +3000,10 @@ module Dommy
2938
3000
  head = @document.create_element("thead")
2939
3001
  cap = caption
2940
3002
  if cap
2941
- cap.__node__.add_next_sibling(head.__node__)
3003
+ cap.__dommy_backend_node__.add_next_sibling(head.__dommy_backend_node__)
2942
3004
  else
2943
3005
  first = @__node__.children.first
2944
- first ? first.add_previous_sibling(head.__node__) : @__node__.add_child(head.__node__)
3006
+ first ? first.add_previous_sibling(head.__dommy_backend_node__) : @__node__.add_child(head.__dommy_backend_node__)
2945
3007
  end
2946
3008
 
2947
3009
  head
@@ -2957,7 +3019,7 @@ module Dommy
2957
3019
  return existing if existing
2958
3020
 
2959
3021
  foot = @document.create_element("tfoot")
2960
- @__node__.add_child(foot.__node__)
3022
+ @__node__.add_child(foot.__dommy_backend_node__)
2961
3023
  foot
2962
3024
  end
2963
3025
 
@@ -2970,9 +3032,9 @@ module Dommy
2970
3032
  tb = @document.create_element("tbody")
2971
3033
  last_tbody = t_bodies.last
2972
3034
  if last_tbody
2973
- last_tbody.__node__.add_next_sibling(tb.__node__)
3035
+ last_tbody.__dommy_backend_node__.add_next_sibling(tb.__dommy_backend_node__)
2974
3036
  else
2975
- @__node__.add_child(tb.__node__)
3037
+ @__node__.add_child(tb.__dommy_backend_node__)
2976
3038
  end
2977
3039
 
2978
3040
  tb
@@ -2995,10 +3057,10 @@ module Dommy
2995
3057
  target_section.append_child(tr)
2996
3058
  else
2997
3059
  anchor = list[idx]
2998
- section = anchor.__node__.parent
3060
+ section = anchor.__dommy_backend_node__.parent
2999
3061
  if section
3000
- anchor.__node__.add_previous_sibling(tr.__node__)
3001
- @document.notify_child_list_mutation(target_node: section, added_nodes: [tr.__node__], removed_nodes: [])
3062
+ anchor.__dommy_backend_node__.add_previous_sibling(tr.__dommy_backend_node__)
3063
+ @document.notify_child_list_mutation(target_node: section, added_nodes: [tr.__dommy_backend_node__], removed_nodes: [])
3002
3064
  end
3003
3065
  end
3004
3066
 
@@ -3066,6 +3128,12 @@ module Dommy
3066
3128
  # absent in Dommy — getters return inert values, `play()` returns
3067
3129
  # a resolved Promise, and `pause()` flips `paused` back to true.
3068
3130
  class HTMLMediaElement < HTMLElement
3131
+ # Own __js_call__ methods, on top of Element's.
3132
+ JS_METHOD_NAMES = %w[play pause load canPlayType].freeze
3133
+ def __js_method_names__
3134
+ super + JS_METHOD_NAMES
3135
+ end
3136
+
3069
3137
  NETWORK_EMPTY = 0
3070
3138
  NETWORK_IDLE = 1
3071
3139
  NETWORK_LOADING = 2
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Custom Nokogiri pseudo-class handlers so CSS selectors like
6
+ # `:disabled` / `:enabled` / `:checked` work in query_selector(_all).
7
+ # Nokogiri calls the method named after the pseudo-class with the current
8
+ # node list and expects the filtered list back. Receives raw Nokogiri
9
+ # nodes (not Dommy wrappers).
10
+ class CSSPseudoHandlers < BasicObject
11
+ include ::Kernel
12
+
13
+ def disabled(list)
14
+ list.find_all { |node| node.has_attribute?("disabled") }
15
+ end
16
+
17
+ def enabled(list)
18
+ list.find_all { |node| !node.has_attribute?("disabled") }
19
+ end
20
+
21
+ def checked(list)
22
+ list.find_all { |node| node.has_attribute?("checked") }
23
+ end
24
+ end
25
+
26
+ CSS_PSEUDO_HANDLERS = CSSPseudoHandlers.new
27
+ end
28
+ end
@@ -57,7 +57,7 @@ module Dommy
57
57
  #
58
58
  # @param html [String]
59
59
  def normalize_html(html)
60
- Nokogiri::HTML5.fragment(html.to_s).to_html.gsub(/\s+/, " ").strip
60
+ Backend.fragment(html.to_s, owner_doc: nil).to_html.gsub(/\s+/, " ").strip
61
61
  end
62
62
 
63
63
  # Get the text_content of a scope, handling Document (which has
@@ -91,9 +91,9 @@ module Dommy
91
91
  # ancestors (head/script/style/template), inline `display:none` /
92
92
  # `visibility:hidden` on element or any ancestor.
93
93
  def visible?(element)
94
- return true unless element.respond_to?(:__node__)
94
+ return true unless element.respond_to?(:__dommy_backend_node__)
95
95
 
96
- node = element.__node__
96
+ node = element.__dommy_backend_node__
97
97
  return false if node_invisible_self?(node)
98
98
 
99
99
  NodeTraversal.each_ancestor(node) do |ancestor|
@@ -10,7 +10,7 @@ module Dommy
10
10
  # Stops at Nokogiri::XML::Document (the root).
11
11
  def self.each_ancestor(node)
12
12
  current = node.respond_to?(:parent) ? node.parent : nil
13
- while current && !current.is_a?(Nokogiri::XML::Document)
13
+ while current && !current.is_a?(Backend.document_class)
14
14
  yield current
15
15
  current = current.respond_to?(:parent) ? current.parent : nil
16
16
  end
@@ -33,15 +33,15 @@ module Dommy
33
33
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
34
34
  raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(NAME_RE)
35
35
 
36
- wrap_node(Nokogiri::XML::Node.new(str.downcase, @document.nokogiri_doc))
36
+ wrap_node(Backend.create_element(str.downcase, @document.nokogiri_doc))
37
37
  end
38
38
 
39
39
  def create_text_node(text)
40
- wrap_node(Nokogiri::XML::Text.new(text.to_s, @document.nokogiri_doc))
40
+ wrap_node(Backend.create_text(text.to_s, @document.nokogiri_doc))
41
41
  end
42
42
 
43
43
  def create_comment(text)
44
- wrap_node(Nokogiri::XML::Comment.new(@document.nokogiri_doc, text.to_s))
44
+ wrap_node(Backend.create_comment(text.to_s, @document.nokogiri_doc))
45
45
  end
46
46
 
47
47
  def create_document_fragment
@@ -69,8 +69,11 @@ module Dommy
69
69
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
70
70
  raise DOMException::InvalidCharacterError, "invalid qualified name: #{str.inspect}" unless str.match?(NAME_RE)
71
71
 
72
- el = Nokogiri::XML::Node.new(str, @document.nokogiri_doc)
73
- el.add_namespace_definition(nil, namespace_uri.to_s) if namespace_uri && !namespace_uri.to_s.empty?
72
+ el = Backend.create_element(str, @document.nokogiri_doc)
73
+ if namespace_uri && !namespace_uri.to_s.empty?
74
+ Backend.add_namespace_definition(el, nil, namespace_uri.to_s)
75
+ end
76
+
74
77
  wrap(el)
75
78
  end
76
79
 
@@ -79,13 +82,13 @@ module Dommy
79
82
  def query_selector(selector)
80
83
  return nil if selector.nil? || selector.to_s.empty?
81
84
 
82
- wrap(@document.nokogiri_doc.at_css(selector.to_s))
85
+ wrap(@document.nokogiri_doc.at_css(selector.to_s, CSS_PSEUDO_HANDLERS))
83
86
  end
84
87
 
85
88
  def query_selector_all(selector)
86
89
  return NodeList.new if selector.nil? || selector.to_s.empty?
87
90
 
88
- NodeList.new(@document.nokogiri_doc.css(selector.to_s).map { |node| wrap(node) }.compact)
91
+ NodeList.new(@document.nokogiri_doc.css(selector.to_s, CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
89
92
  end
90
93
 
91
94
  def get_element_by_id(id)
@@ -131,6 +134,14 @@ module Dommy
131
134
  @wrappers.delete(nokogiri_node.object_id)
132
135
  end
133
136
 
137
+ # Register an externally-built wrapper. Used by
138
+ # Document#adopt_node when migrating a wrapper from another
139
+ # document so the existing Ruby object survives the move
140
+ # rather than being replaced by a freshly-built one.
141
+ def register(nokogiri_node, wrapper)
142
+ @wrappers[nokogiri_node.object_id] = wrapper
143
+ end
144
+
134
145
  private
135
146
 
136
147
  def wrap_node(node)
@@ -139,20 +150,20 @@ module Dommy
139
150
 
140
151
  def build_wrapper_for(node)
141
152
  case node
142
- when Nokogiri::XML::Element
153
+ when Backend.element_class
143
154
  build_element_wrapper(node)
144
- when Nokogiri::XML::Text
155
+ when Backend.text_class
145
156
  TextNode.new(@document, node)
146
- when Nokogiri::XML::Comment
157
+ when Backend.comment_class
147
158
  CommentNode.new(@document, node)
148
- when Nokogiri::XML::DocumentFragment
159
+ when Backend.document_fragment_class
149
160
  Fragment.new(@document, node)
150
161
  end
151
162
  end
152
163
 
153
164
  def build_element_wrapper(node)
154
165
  custom_klass = custom_element_class_for(node.name)
155
- klass = custom_klass || Dommy.element_class_for(node.name, node.namespace&.href)
166
+ klass = custom_klass || Dommy.element_class_for(node.name, Backend.namespace_of(node)&.href)
156
167
  instance = klass.new(@document, node)
157
168
 
158
169
  @wrappers[node.object_id] = instance
@@ -19,16 +19,16 @@ module Dommy
19
19
  # Parse HTML into a fragment and attach it as the template's content.
20
20
  # Drops any pre-existing direct children of the template element.
21
21
  def attach(template_element, html)
22
- template_element.__node__.children.each(&:unlink)
22
+ template_element.__dommy_backend_node__.children.each(&:unlink)
23
23
  fragment = @document.nokogiri_doc.fragment(html.to_s)
24
- @fragments[template_element.__node__.object_id] = fragment
24
+ @fragments[template_element.__dommy_backend_node__.object_id] = fragment
25
25
  fragment
26
26
  end
27
27
 
28
28
  # Get the wrapped Fragment for a template element, seeding from
29
29
  # the template's current children if not previously migrated.
30
30
  def fragment_for(template_element)
31
- fragment = @fragments[template_element.__node__.object_id]
31
+ fragment = @fragments[template_element.__dommy_backend_node__.object_id]
32
32
  fragment ||= seed(template_element)
33
33
  @document.wrap_node(fragment)
34
34
  end
@@ -40,7 +40,7 @@ module Dommy
40
40
  end
41
41
 
42
42
  def inner_html_of(template_element)
43
- fragment = @fragments[template_element.__node__.object_id]
43
+ fragment = @fragments[template_element.__dommy_backend_node__.object_id]
44
44
  return "" unless fragment
45
45
 
46
46
  fragment.children.map(&:to_html).join
@@ -79,8 +79,8 @@ module Dommy
79
79
  end
80
80
 
81
81
  def seed(template_element)
82
- migrate_one(template_element.__node__)
83
- @fragments[template_element.__node__.object_id]
82
+ migrate_one(template_element.__dommy_backend_node__)
83
+ @fragments[template_element.__dommy_backend_node__.object_id]
84
84
  end
85
85
 
86
86
  def migrate_one(template_node)
@@ -3,7 +3,7 @@
3
3
  module Dommy
4
4
  # `IntersectionObserver` — stub for the viewport-intersection API.
5
5
  # Dommy has no layout engine, so callbacks never fire automatically.
6
- # Tests can drive callbacks explicitly via `__trigger__(entries)`.
6
+ # Tests can drive callbacks explicitly via `__test_trigger__(entries)`.
7
7
  #
8
8
  # Spec: https://w3c.github.io/IntersectionObserver/
9
9
  class IntersectionObserver
@@ -51,7 +51,7 @@ module Dommy
51
51
 
52
52
  # Test seam: invoke the callback with a synthetic entries list.
53
53
  # Each entry is whatever shape the test wants (usually a Hash).
54
- def __trigger__(entries)
54
+ def __test_trigger__(entries)
55
55
  invoke_callback(entries)
56
56
  end
57
57
 
@@ -5,7 +5,7 @@ require "uri"
5
5
  module Dommy
6
6
  # `window.location` polyfill. The Window owns one Location and one
7
7
  # History instance, and they share the same underlying state. Hash
8
- # / pushState / replaceState all flow through `__set_url__`.
8
+ # / pushState / replaceState all flow through `__internal_set_url__`.
9
9
  class Location
10
10
  def initialize(window, origin: "http://localhost", pathname: "/", search: "", hash: "")
11
11
  @window = window
@@ -41,7 +41,7 @@ module Dommy
41
41
  def __js_set__(key, value)
42
42
  case key
43
43
  when "href"
44
- __set_url__(value.to_s)
44
+ __internal_set_url__(value.to_s)
45
45
  when "hash"
46
46
  new_hash = value.to_s
47
47
  new_hash = "##{new_hash}" unless new_hash.empty? || new_hash.start_with?("#")
@@ -68,7 +68,7 @@ module Dommy
68
68
  def __js_call__(method, args)
69
69
  case method
70
70
  when "assign", "replace"
71
- __set_url__(args[0].to_s)
71
+ __internal_set_url__(args[0].to_s)
72
72
  when "reload"
73
73
  nil
74
74
  when "toString"
@@ -83,12 +83,16 @@ module Dommy
83
83
  # Internal — accepts an absolute or relative URL string and
84
84
  # updates pathname / search / hash. Called by History pushState /
85
85
  # replaceState and by `location.href = ...`.
86
- def __set_url__(raw)
86
+ def __internal_set_url__(raw)
87
87
  previous_hash = @hash
88
88
  if raw.start_with?("#")
89
89
  @hash = raw
90
90
  else
91
91
  uri = URI.join(@origin + @pathname + @search + @hash, raw) rescue URI(raw)
92
+ # An absolute URL (carrying scheme + host) navigates to a new
93
+ # origin; a relative URL inherits the current origin from the
94
+ # join base, so rebuilding with the same parts is a no-op.
95
+ rebuild_origin(scheme: uri.scheme, host: uri.host, port: uri.port) if uri.scheme && uri.host
92
96
  @pathname = uri.path.to_s == "" ? "/" : uri.path
93
97
  @search = uri.query ? "?#{uri.query}" : ""
94
98
  @hash = uri.fragment ? "##{uri.fragment}" : ""