dommy 0.13.0 → 0.14.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +63 -29
  3. data/lib/dommy/backend.rb +32 -9
  4. data/lib/dommy/bridge/constructor.rb +72 -0
  5. data/lib/dommy/bridge/errors.rb +42 -0
  6. data/lib/dommy/bridge/methods.rb +21 -14
  7. data/lib/dommy/bridge/sentinels.rb +39 -0
  8. data/lib/dommy/bridge/values.rb +78 -0
  9. data/lib/dommy/{js → bridge}/wire_tags.rb +14 -11
  10. data/lib/dommy/bridge.rb +22 -250
  11. data/lib/dommy/browser.rb +112 -11
  12. data/lib/dommy/character_data.rb +539 -0
  13. data/lib/dommy/class_list.rb +311 -0
  14. data/lib/dommy/crypto.rb +12 -1
  15. data/lib/dommy/css.rb +12 -0
  16. data/lib/dommy/data_transfer.rb +106 -0
  17. data/lib/dommy/document.rb +307 -369
  18. data/lib/dommy/dom_parser.rb +3 -0
  19. data/lib/dommy/element.rb +199 -1817
  20. data/lib/dommy/encodings/tables.rb +4 -4
  21. data/lib/dommy/encodings.rb +58 -27
  22. data/lib/dommy/event.rb +227 -1
  23. data/lib/dommy/event_source.rb +41 -0
  24. data/lib/dommy/form_data.rb +6 -64
  25. data/lib/dommy/form_entry_list.rb +227 -0
  26. data/lib/dommy/fragment.rb +320 -0
  27. data/lib/dommy/history.rb +3 -0
  28. data/lib/dommy/html_collection.rb +18 -8
  29. data/lib/dommy/html_elements/base.rb +338 -0
  30. data/lib/dommy/html_elements/embedded.rb +237 -0
  31. data/lib/dommy/html_elements/forms/form.rb +1088 -0
  32. data/lib/dommy/html_elements/forms/input.rb +642 -0
  33. data/lib/dommy/html_elements/forms/select.rb +536 -0
  34. data/lib/dommy/html_elements/forms.rb +8 -0
  35. data/lib/dommy/html_elements/interactive.rb +382 -0
  36. data/lib/dommy/html_elements/media.rb +329 -0
  37. data/lib/dommy/html_elements/metadata.rb +353 -0
  38. data/lib/dommy/html_elements/miscellaneous.rb +77 -0
  39. data/lib/dommy/html_elements/tables.rb +468 -0
  40. data/lib/dommy/html_elements.rb +11 -5322
  41. data/lib/dommy/hyperlink_utils.rb +171 -0
  42. data/lib/dommy/interaction/debug.rb +7 -6
  43. data/lib/dommy/interaction/dom_summary.rb +65 -40
  44. data/lib/dommy/interaction/driver.rb +29 -115
  45. data/lib/dommy/interaction/event_synthesis.rb +97 -4
  46. data/lib/dommy/interaction/field_interactor.rb +1 -13
  47. data/lib/dommy/interaction/form_submission.rb +89 -167
  48. data/lib/dommy/interaction/key_sender.rb +121 -0
  49. data/lib/dommy/interaction/mime_types.rb +25 -0
  50. data/lib/dommy/interaction.rb +2 -0
  51. data/lib/dommy/internal/accessibility_tree.rb +32 -9
  52. data/lib/dommy/internal/accessibility_visibility.rb +44 -0
  53. data/lib/dommy/internal/accessible_name.rb +85 -65
  54. data/lib/dommy/internal/aria_role.rb +21 -6
  55. data/lib/dommy/internal/aria_snapshot.rb +5 -0
  56. data/lib/dommy/internal/aria_state.rb +24 -5
  57. data/lib/dommy/internal/autofill.rb +141 -0
  58. data/lib/dommy/internal/backend_prefilter.rb +170 -0
  59. data/lib/dommy/internal/bounded_cache.rb +50 -0
  60. data/lib/dommy/internal/child_node.rb +9 -34
  61. data/lib/dommy/internal/css/calc.rb +143 -145
  62. data/lib/dommy/internal/css/cascade.rb +38 -393
  63. data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
  64. data/lib/dommy/internal/css/color.rb +61 -34
  65. data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
  66. data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
  67. data/lib/dommy/internal/css/counters.rb +60 -37
  68. data/lib/dommy/internal/css/custom_properties.rb +87 -39
  69. data/lib/dommy/internal/css/media_query.rb +228 -203
  70. data/lib/dommy/internal/css/parser.rb +29 -12
  71. data/lib/dommy/internal/css/property_registry.rb +32 -56
  72. data/lib/dommy/internal/css/renderability.rb +61 -0
  73. data/lib/dommy/internal/css/rule_index.rb +17 -140
  74. data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
  75. data/lib/dommy/internal/css/style_cache.rb +61 -0
  76. data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
  77. data/lib/dommy/internal/css/value_tokens.rb +60 -0
  78. data/lib/dommy/internal/directionality.rb +188 -0
  79. data/lib/dommy/internal/document_generations.rb +169 -0
  80. data/lib/dommy/internal/document_interaction_state.rb +47 -0
  81. data/lib/dommy/internal/document_live_ranges.rb +97 -0
  82. data/lib/dommy/internal/dom_matching.rb +28 -12
  83. data/lib/dommy/internal/element_aria.rb +163 -0
  84. data/lib/dommy/internal/element_geometry.rb +69 -0
  85. data/lib/dommy/internal/element_matching.rb +5 -12
  86. data/lib/dommy/internal/element_shadow.rb +117 -0
  87. data/lib/dommy/internal/element_state.rb +232 -0
  88. data/lib/dommy/internal/element_tasks.rb +19 -0
  89. data/lib/dommy/internal/element_top_layer.rb +63 -0
  90. data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
  91. data/lib/dommy/internal/exception_report.rb +1 -1
  92. data/lib/dommy/internal/input_type.rb +280 -0
  93. data/lib/dommy/internal/insertion_point.rb +62 -0
  94. data/lib/dommy/internal/mime_type.rb +74 -0
  95. data/lib/dommy/internal/mutation_coordinator.rb +57 -198
  96. data/lib/dommy/internal/namespaces.rb +41 -43
  97. data/lib/dommy/internal/node_adopter.rb +84 -77
  98. data/lib/dommy/internal/node_factory.rb +157 -0
  99. data/lib/dommy/internal/node_identity.rb +31 -0
  100. data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
  101. data/lib/dommy/internal/observer_options.rb +93 -0
  102. data/lib/dommy/internal/parent_node.rb +27 -27
  103. data/lib/dommy/internal/post_insertion_steps.rb +213 -0
  104. data/lib/dommy/internal/reflected_attributes.rb +425 -13
  105. data/lib/dommy/internal/rendered_text/collector.rb +221 -0
  106. data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
  107. data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
  108. data/lib/dommy/internal/rendered_text/items.rb +57 -0
  109. data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
  110. data/lib/dommy/internal/selector_arguments.rb +94 -0
  111. data/lib/dommy/internal/selector_index.rb +3 -1
  112. data/lib/dommy/internal/selector_matcher.rb +23 -383
  113. data/lib/dommy/internal/selector_parser.rb +2 -37
  114. data/lib/dommy/internal/template_content_registry.rb +112 -66
  115. data/lib/dommy/internal/text_flattening.rb +20 -0
  116. data/lib/dommy/internal/text_selection.rb +104 -0
  117. data/lib/dommy/internal/toggle_events.rb +56 -0
  118. data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
  119. data/lib/dommy/internal/url_parser.rb +722 -569
  120. data/lib/dommy/internal/url_record_accessors.rb +90 -0
  121. data/lib/dommy/internal/window_constructors.rb +190 -0
  122. data/lib/dommy/internal/xml_serialization.rb +168 -80
  123. data/lib/dommy/js/crossing_profile.rb +75 -0
  124. data/lib/dommy/js/dom_interfaces.rb +22 -2
  125. data/lib/dommy/js/host_bridge.rb +43 -187
  126. data/lib/dommy/js/host_callbacks.rb +96 -0
  127. data/lib/dommy/js/host_runtime.js +840 -763
  128. data/lib/dommy/js/import_map.rb +10 -1
  129. data/lib/dommy/js/marshaller.rb +94 -65
  130. data/lib/dommy/js/observable_runtime.js +125 -146
  131. data/lib/dommy/js/runtime.rb +10 -43
  132. data/lib/dommy/js/runtime_registry.rb +46 -0
  133. data/lib/dommy/js/script_boot.rb +38 -73
  134. data/lib/dommy/js/webidl_tables.js +619 -0
  135. data/lib/dommy/js/window_wiring.rb +69 -0
  136. data/lib/dommy/location.rb +152 -103
  137. data/lib/dommy/mutation_observer.rb +3 -57
  138. data/lib/dommy/navigation.rb +65 -12
  139. data/lib/dommy/node.rb +46 -20
  140. data/lib/dommy/promise.rb +58 -4
  141. data/lib/dommy/resources.rb +19 -1
  142. data/lib/dommy/shadow_root.rb +11 -5
  143. data/lib/dommy/style_declaration.rb +284 -0
  144. data/lib/dommy/svg_elements.rb +1 -45
  145. data/lib/dommy/url.rb +20 -69
  146. data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
  147. data/lib/dommy/url_pattern/tokenizer.rb +4 -2
  148. data/lib/dommy/validity_state.rb +383 -0
  149. data/lib/dommy/version.rb +1 -1
  150. data/lib/dommy/window.rb +30 -166
  151. data/lib/dommy/xml_http_request.rb +35 -4
  152. data/lib/dommy.rb +23 -3
  153. metadata +74 -7
  154. data/lib/dommy/bridge/constructor_registry.rb +0 -28
  155. data/lib/dommy/internal/css_pseudo_handlers.rb +0 -378
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # DOMTokenList (`classList`, `relList`) and DOMStringMap (`dataset`) —
5
+ # the two live views over one attribute's text.
6
+ #
7
+ # Lived in element.rb, which is for Element.
8
+ class ClassList
9
+ include Enumerable
10
+
11
+ # `attribute` is the content attribute this token list reflects ("class" for
12
+ # `classList`, "rel" for `relList`, "sandbox", "sizes", "for", …).
13
+ def initialize(element, attribute = "class")
14
+ @element = element
15
+ @attribute = attribute
16
+ end
17
+
18
+ def length
19
+ class_tokens.length
20
+ end
21
+
22
+ alias size length
23
+
24
+ def item(index)
25
+ i = index.to_i
26
+ return nil if i.negative?
27
+
28
+ class_tokens[i]
29
+ end
30
+
31
+ def value
32
+ @element.__dommy_backend_node__[@attribute].to_s
33
+ end
34
+
35
+ def value=(new_value)
36
+ @element.set_attribute(@attribute, new_value.to_s)
37
+ end
38
+
39
+ # Spec: contains() does NOT validate (no SyntaxError on empty).
40
+ def contains?(token)
41
+ class_tokens.include?(token.to_s)
42
+ end
43
+
44
+ # DOMTokenList membership. Defined explicitly (rather than inheriting
45
+ # Enumerable#include?, which re-iterates via #each) so a class-selector match
46
+ # is a single Array#include? over the cached tokens — the hot path under a
47
+ # querySelector-heavy SPA.
48
+ def include?(token)
49
+ class_tokens.include?(token.to_s)
50
+ end
51
+
52
+ def add(*tokens)
53
+ update_tokens { |existing| existing | normalize_tokens(tokens) }
54
+ nil
55
+ end
56
+
57
+ def remove(*tokens)
58
+ update_tokens { |existing| existing - normalize_tokens(tokens) }
59
+ nil
60
+ end
61
+
62
+ def replace(old_token, new_token)
63
+ # Spec order: both tokens' empty checks (SyntaxError) precede both
64
+ # whitespace checks (InvalidCharacterError) — so replace(" ", "") is a
65
+ # SyntaxError (the empty newToken), not an InvalidCharacterError.
66
+ old_s = stringify_token(old_token)
67
+ new_s = stringify_token(new_token)
68
+ raise DOMException::SyntaxError, "token is empty" if old_s.empty? || new_s.empty?
69
+ if old_s.match?(/[ \t\n\f\r]/) || new_s.match?(/[ \t\n\f\r]/)
70
+ raise DOMException::InvalidCharacterError, "token contains whitespace"
71
+ end
72
+
73
+ tokens = class_tokens
74
+ idx = tokens.index(old_s)
75
+ return false unless idx
76
+
77
+ # class_tokens returns the cached token array; dup before mutating so the
78
+ # in-place assignment can't corrupt the cache (whose key is still the old
79
+ # raw attribute string, which would then hand stale tokens to later reads).
80
+ updated = tokens.dup
81
+ updated[idx] = new_s
82
+ @element.set_attribute(@attribute, updated.uniq.join(" "))
83
+ true
84
+ end
85
+
86
+ def [](index)
87
+ item(index)
88
+ end
89
+
90
+ def each(&blk)
91
+ class_tokens.each(&blk)
92
+ end
93
+
94
+ def to_a
95
+ class_tokens.dup
96
+ end
97
+
98
+ def to_s
99
+ value
100
+ end
101
+
102
+ def __js_get__(key)
103
+ case key
104
+ when "length"
105
+ length
106
+ when "value"
107
+ value
108
+ else
109
+ # Indexed getter: `classList[i]` is an undefined-returning indexed
110
+ # property — out-of-range or negative indices yield JS `undefined`
111
+ # (unlike `item(i)`, which returns null). Returning Ruby nil here would
112
+ # marshal as JS null, so use the UNDEFINED sentinel.
113
+ if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
114
+ i = key.to_i
115
+ token = i.negative? ? nil : class_tokens[i]
116
+ token.nil? ? Bridge::UNDEFINED : token
117
+ else
118
+ Bridge::ABSENT # unknown non-index property
119
+ end
120
+ end
121
+ end
122
+
123
+ def __js_set__(key, val)
124
+ case key
125
+ when "value"
126
+ self.value = val
127
+ end
128
+
129
+ nil
130
+ end
131
+
132
+ include Bridge::Methods
133
+ # NOTE: `supports` is intentionally absent — for the class attribute's token
134
+ # list it must throw a TypeError, which `list.supports(...)` (not a function)
135
+ # already does.
136
+ js_methods %w[add remove contains toggle replace item toString]
137
+ def __js_call__(method, args)
138
+ case method
139
+ when "add"
140
+ update_tokens { |tokens| tokens | normalize_tokens(args) }
141
+ Bridge::UNDEFINED
142
+ when "remove"
143
+ update_tokens { |tokens| tokens - normalize_tokens(args) }
144
+ Bridge::UNDEFINED
145
+ when "contains"
146
+ # contains() does not validate; null coerces to the string "null".
147
+ class_tokens.include?(stringify_token(args[0]))
148
+ when "toggle"
149
+ toggle(args[0], args[1])
150
+ when "replace"
151
+ replace(args[0], args[1])
152
+ when "item"
153
+ item(args[0])
154
+ when "toString"
155
+ value
156
+ else
157
+ nil
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def toggle(token, force)
164
+ name = validate_token(token)
165
+ present = class_tokens.include?(name)
166
+ force_given = !(force.nil? || force.equal?(Bridge::UNDEFINED))
167
+
168
+ # Spec: toggle runs the update steps only when it actually adds or removes.
169
+ # With an explicit force that already matches the current state it's a
170
+ # no-op — the attribute is left byte-for-byte untouched (no re-serialize).
171
+ if force_given
172
+ want = !!force
173
+ return want if want == present
174
+
175
+ update_tokens { |tokens| want ? tokens | [name] : tokens - [name] }
176
+ return want
177
+ end
178
+
179
+ desired = !present
180
+ update_tokens { |tokens| desired ? tokens | [name] : tokens - [name] }
181
+ desired
182
+ end
183
+
184
+ # USVString coercion of a token argument: JS `null` becomes the string
185
+ # "null" (so `add(null)` adds the token "null"), not the empty string.
186
+ def stringify_token(token)
187
+ token.nil? ? "null" : token.to_s
188
+ end
189
+
190
+ # Spec: any empty-string argument throws SyntaxError; any token
191
+ # containing ASCII whitespace throws InvalidCharacterError. Applies
192
+ # to add / remove / replace / toggle.
193
+ def normalize_tokens(args)
194
+ args.map { |t| validate_token(t) }
195
+ end
196
+
197
+ def validate_token(token)
198
+ s = stringify_token(token)
199
+ raise DOMException::SyntaxError, "token is empty" if s.empty?
200
+ raise DOMException::InvalidCharacterError, "token contains whitespace: #{s.inspect}" if s.match?(/\s/)
201
+
202
+ s
203
+ end
204
+
205
+ # The DOMTokenList token set: the class attribute parsed as an *ordered set*
206
+ # (whitespace-split, duplicates removed preserving first-seen order). length,
207
+ # item, iteration, and contains all operate on this set; `value`/`toString`
208
+ # return the raw attribute. ASCII whitespace per the spec is space/tab/LF/FF/CR.
209
+ def class_tokens
210
+ raw = @element.__dommy_backend_node__[@attribute].to_s
211
+ # Cache the parsed token list keyed by the raw attribute string: a class
212
+ # selector match re-reads this for every element on every querySelector,
213
+ # and the split/reject/uniq dominated heavy-SPA load profiles. The key is
214
+ # the raw value itself, so any change (add/remove/className=, or a direct
215
+ # backend mutation) yields a different key and transparently recomputes.
216
+ cached = @token_cache
217
+ return cached[1] if cached && cached[0] == raw
218
+
219
+ tokens = raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
220
+ @token_cache = [raw, tokens]
221
+ tokens
222
+ end
223
+
224
+ # DOMTokenList "update steps": serialize the (deduplicated) token set back to
225
+ # the class attribute. add/remove/replace always run this, so duplicates
226
+ # collapse and whitespace normalizes even on a no-op token. The one carve-out
227
+ # (per spec) is an empty set with no existing attribute — don't create one.
228
+ def update_tokens
229
+ tokens = yield(class_tokens)
230
+ return if tokens.empty? && !@element.__dommy_backend_node__.key?(@attribute)
231
+
232
+ @element.set_attribute(@attribute, tokens.join(" "))
233
+ end
234
+ end
235
+
236
+ # `Element#dataset` proxy. `el.dataset.fooBar` reads / writes
237
+ # `data-foo-bar` per the HTMLOrForeignElement.dataset spec
238
+ # (camelCase ↔ kebab-case round-trip).
239
+ class DatasetMap
240
+ def initialize(element)
241
+ @element = element
242
+ end
243
+
244
+ def __js_get__(key)
245
+ name = key.to_s
246
+ return Bridge::ABSENT unless supported_name?(name)
247
+
248
+ # A missing data-* attribute reads as JS `undefined` (and `"foo" in dataset`
249
+ # is false), per DOMStringMap semantics.
250
+ value = @element.__dommy_backend_node__[attr_name(name)]
251
+ value.nil? ? Bridge::ABSENT : value
252
+ end
253
+
254
+ def __js_set__(key, value)
255
+ name = key.to_s
256
+ raise DOMException::SyntaxError, "#{name.inspect} is not a valid dataset name" unless supported_name?(name)
257
+
258
+ attribute = attr_name(name)
259
+ unless Internal::Namespaces.valid_attribute_local_name?(attribute)
260
+ raise DOMException::InvalidCharacterError, "#{attribute.inspect} is not a valid attribute name"
261
+ end
262
+
263
+ @element.set_attribute(attribute, value.to_s)
264
+ nil
265
+ end
266
+
267
+ # Named deleter (`delete el.dataset.foo`): removes the data-* attribute. A
268
+ # name that is not supported is silently left alone (it names nothing to
269
+ # delete).
270
+ def __js_delete__(key)
271
+ name = key.to_s
272
+ return true unless supported_name?(name)
273
+
274
+ @element.remove_attribute(attr_name(name))
275
+ true
276
+ end
277
+
278
+ def __js_call__(_method, _args)
279
+ nil
280
+ end
281
+
282
+ # WebIDL "supported property names" for DOMStringMap: each `data-*`
283
+ # attribute's name with the `data-` prefix stripped and `-x` sequences
284
+ # camel-cased (`data-date-of-birth` → `dateOfBirth`, `data-` → ``).
285
+ def __js_named_props__
286
+ Backend.attribute_nodes(@element.__dommy_backend_node__).filter_map do |a|
287
+ name = Backend.attribute_ns_info(a)[:qualified_name]
288
+ next unless name.start_with?("data-")
289
+
290
+ name.sub(/\Adata-/, "").gsub(/-([a-z])/) { ::Regexp.last_match(1).upcase }
291
+ end
292
+ end
293
+
294
+ private
295
+
296
+ # Whether `name` can be a supported property name. The camel-casing of an
297
+ # attribute name never leaves a `-` before an ASCII lowercase letter
298
+ # (`data--foo` is `Foo`, never `-foo`), so such a name is none: the getter
299
+ # finds nothing, the setter throws, and the deleter does nothing.
300
+ def supported_name?(name) = !name.match?(/-[a-z]/)
301
+
302
+ def attr_name(key)
303
+ "data-#{key.to_s.gsub(/[A-Z]/) { |m| "-#{m.downcase}" }}"
304
+ end
305
+ end
306
+
307
+ # Stub `DOMRect` for `getBoundingClientRect` — no layout engine,
308
+ # so all values are 0. Consumer code that uses these for *relative*
309
+ # positioning sees zeroed values; absolute layout assertions need
310
+ # the real browser.
311
+ end
data/lib/dommy/crypto.rb CHANGED
@@ -51,6 +51,15 @@ module Dommy
51
51
 
52
52
  alias getRandomValues get_random_values
53
53
 
54
+ # `length` random bytes, for the JS `getRandomValues` stub: it validates the
55
+ # caller's typed array and copies these bytes into it, so the array's type
56
+ # and identity survive (see host_runtime.js's randomValuesStub). Not a WebIDL
57
+ # method — the stub calls it as `__internal_random_bytes__`, a name kept
58
+ # apart from the WebIDL `getRandomValues`.
59
+ def random_bytes(length)
60
+ Bridge::Bytes.new(SecureRandom.bytes(length).bytes)
61
+ end
62
+
54
63
  def subtle
55
64
  @subtle ||= SubtleCrypto.new(@window)
56
65
  end
@@ -65,13 +74,15 @@ module Dommy
65
74
  end
66
75
 
67
76
  include Bridge::Methods
68
- js_methods %w[randomUUID getRandomValues]
77
+ js_methods %w[randomUUID getRandomValues __internal_random_bytes__]
69
78
  def __js_call__(method, args)
70
79
  case method
71
80
  when "randomUUID"
72
81
  random_uuid
73
82
  when "getRandomValues"
74
83
  get_random_values(args[0])
84
+ when "__internal_random_bytes__"
85
+ random_bytes(args[0])
75
86
  end
76
87
  end
77
88
  end
data/lib/dommy/css.rb CHANGED
@@ -280,6 +280,14 @@ module Dommy
280
280
  end
281
281
  end
282
282
 
283
+ # `StyleSheetList` — the CSSStyleSheets of a document's or shadow root's
284
+ # <style> / <link rel=stylesheet> elements, live. Its IDL has an indexed
285
+ # getter and `length` but no `iterable<>`, so it gets @@iterator and NOT the
286
+ # values/entries/forEach that NodeList has — reporting it as a NodeList (as it
287
+ # once was) leaked Array.prototype's pair methods.
288
+ class StyleSheetList < LiveList
289
+ end
290
+
283
291
  # `CSSStyleRule#style` — a live, mutable CSSStyleDeclaration backed by a
284
292
  # rule's declaration block. Reads come from the parsed block; every write
285
293
  # reserializes the block and hands it back to the owning CSSRule, which
@@ -603,6 +611,10 @@ module Dommy
603
611
  case key
604
612
  when "cssText" then self.css_text = value
605
613
  when "selectorText" then self.selector_text = value
614
+ when "style"
615
+ # CSSStyleRule.style is [PutForwards=cssText]: assigning to it writes
616
+ # the declaration block (`rule.style = "color: red"`). nil on at-rules.
617
+ style&.css_text = value
606
618
  when "media"
607
619
  # CSSMediaRule#media is settable with a media-text string.
608
620
  __internal_set_media__(value.to_s) if type == MEDIA_RULE
@@ -45,10 +45,23 @@ module Dommy
45
45
 
46
46
  attr_accessor :drop_effect, :effect_allowed
47
47
 
48
+ def items
49
+ @items ||= DataTransferItemList.new(self)
50
+ end
51
+
52
+ # Called by the item list when a File/Blob is added through `items.add`.
53
+ # FileList is immutable, so rebuild it with the new file appended.
54
+ def __internal_add_file__(file)
55
+ @files = FileList.new(@files.to_a + [file])
56
+ nil
57
+ end
58
+
48
59
  def __js_get__(key)
49
60
  case key
50
61
  when "files"
51
62
  @files
63
+ when "items"
64
+ items
52
65
  when "types"
53
66
  types
54
67
  when "dropEffect"
@@ -101,4 +114,97 @@ module Dommy
101
114
  end
102
115
  end
103
116
  end
117
+
118
+ # `DataTransferItemList` — `dataTransfer.items`. The tests build one to seed a
119
+ # file input: `dt.items.add(file); input.files = dt.files`. A Blob/File adds a
120
+ # file item (and appends to `files`); anything else adds a string item.
121
+ class DataTransferItemList
122
+ include Bridge::Methods
123
+ js_methods %w[add remove clear]
124
+
125
+ def initialize(owner)
126
+ @owner = owner
127
+ @items = []
128
+ end
129
+
130
+ def add(data, type = nil)
131
+ item = DataTransferItem.new(data, type)
132
+ @items << item
133
+ if data.is_a?(Blob)
134
+ @owner.__internal_add_file__(data)
135
+ else
136
+ @owner.set_data(type.to_s.empty? ? "text/plain" : type.to_s, data.to_s)
137
+ end
138
+ item
139
+ end
140
+
141
+ def remove(index)
142
+ @items.delete_at(index.to_i)
143
+ nil
144
+ end
145
+
146
+ def clear
147
+ @items.clear
148
+ nil
149
+ end
150
+
151
+ def length = @items.length
152
+
153
+ def __js_get__(key)
154
+ return length if key == "length"
155
+
156
+ index = Integer(key, exception: false)
157
+ return Bridge::ABSENT unless index&.between?(0, @items.length - 1)
158
+
159
+ @items[index]
160
+ end
161
+
162
+ def __js_call__(method, args)
163
+ case method
164
+ when "add" then add(args[0], args[1])
165
+ when "remove" then remove(args[0])
166
+ when "clear" then clear
167
+ end
168
+ end
169
+ end
170
+
171
+ # `DataTransferItem` — one entry in the list, a file or a string item.
172
+ class DataTransferItem
173
+ include Bridge::Methods
174
+ js_methods %w[getAsFile getAsString]
175
+
176
+ def initialize(data, type = nil)
177
+ @data = data
178
+ @type = if type.to_s.empty?
179
+ data.is_a?(Blob) ? data.type.to_s : "text/plain"
180
+ else
181
+ type.to_s
182
+ end
183
+ end
184
+
185
+ def kind = @data.is_a?(Blob) ? "file" : "string"
186
+ def type = @type
187
+
188
+ def get_as_file = @data.is_a?(Blob) ? @data : nil
189
+
190
+ def get_as_string(callback)
191
+ callback&.call(@data.to_s)
192
+ nil
193
+ end
194
+
195
+ def __js_get__(key)
196
+ case key
197
+ when "kind" then kind
198
+ when "type" then type
199
+ else Bridge::ABSENT
200
+ end
201
+ end
202
+
203
+ def __js_call__(method, args)
204
+ case method
205
+ when "getAsFile" then get_as_file
206
+ when "getAsString" then get_as_string(args[0])
207
+ end
208
+ end
209
+ end
104
210
  end