dommy 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/css.rb CHANGED
@@ -1,21 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "internal/css/supports"
4
+
3
5
  module Dommy
4
- # `CSSStyleSheet` — stub implementation. Dommy has no CSS parser
5
- # nor a render tree, so we don't interpret rule text; the sheet
6
- # acts as an ordered list of opaque `CSSRule`-like wrappers.
7
- #
8
- # Useful for code that does:
6
+ # `CSSStyleSheet` — the sheet itself doesn't interpret rule text; it
7
+ # acts as an ordered list of opaque `CSSRule`-like wrappers. The
8
+ # cascade consumes the sheet through `cascade_text` (all rule texts
9
+ # in document order), so insertRule/deleteRule/replaceSync and
10
+ # `disabled` are reflected in computed styles for `<style>`-owned
11
+ # sheets (Internal::CSS::RuleIndex re-parses on the next lookup).
9
12
  #
10
13
  # sheet.insertRule("p { color: red }", 0);
11
14
  # for (const r of sheet.cssRules) console.log(r.cssText);
12
15
  #
13
- # `disabled` is honored as state. `href`, `media`, `title`, `type`
14
- # mirror the owner node's attributes when present.
16
+ # `href`, `media`, `title`, `type` mirror the owner node's
17
+ # attributes when present.
15
18
  class CSSStyleSheet
16
19
  attr_reader :owner_node, :css_rules
17
20
 
18
- def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css")
21
+ def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
19
22
  @owner_node = owner_node
20
23
  @href = href
21
24
  @media = media
@@ -23,6 +26,13 @@ module Dommy
23
26
  @type = type
24
27
  @disabled = false
25
28
  @css_rules = CSSRuleList.new
29
+ # The owner node's CSS text at sheet creation, split into one CSSRule
30
+ # per top-level rule (source order) so cssRules mirrors the parsed
31
+ # sheet — selectorText / style / nested cssRules read off each slice.
32
+ # insertRule(0) lands before them, appends after, as a real sheet would.
33
+ Internal::CSSRuleText.split_rules(source_text).each_with_index do |slice, i|
34
+ @css_rules.__internal_insert__(i, CSSRule.new(slice, self))
35
+ end
26
36
  end
27
37
 
28
38
  def disabled
@@ -30,7 +40,18 @@ module Dommy
30
40
  end
31
41
 
32
42
  def disabled=(v)
33
- @disabled = !!v
43
+ v = !!v
44
+ changed = @disabled != v
45
+ @disabled = v
46
+ __internal_bump_owner_style_generation__ if changed
47
+ v
48
+ end
49
+
50
+ # The sheet's full CSS text in document order — what the cascade
51
+ # parses in place of the owner `<style>`'s raw text once the sheet
52
+ # has been mutated through the CSSOM.
53
+ def cascade_text
54
+ @css_rules.map(&:css_text).join("\n")
34
55
  end
35
56
 
36
57
  def href
@@ -57,31 +78,55 @@ module Dommy
57
78
  nil
58
79
  end
59
80
 
60
- # `insertRule(rule_text, index)` — appends an opaque CSSRule at the
61
- # given position (default: end). Returns the index used.
81
+ # `insertRule(rule, index)` — `rule` is required; inserts a CSSRule at the
82
+ # given position (Dommy defaults to the end). Returns the index used.
62
83
  def insert_rule(rule_text, index = nil)
84
+ raise Bridge::TypeError, "insertRule requires a rule" if rule_text.nil?
85
+
63
86
  idx = index.nil? ? @css_rules.length : index.to_i
64
87
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx > @css_rules.length
65
88
 
66
89
  @css_rules.__internal_insert__(idx, CSSRule.new(rule_text.to_s, self))
90
+ __internal_bump_owner_style_generation__
67
91
  idx
68
92
  end
69
93
 
94
+ # `deleteRule(index)` — `index` is required.
70
95
  def delete_rule(index)
96
+ raise Bridge::TypeError, "deleteRule requires an index" if index.nil?
97
+
71
98
  idx = index.to_i
72
99
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx >= @css_rules.length
73
100
 
74
101
  @css_rules.__internal_delete_at__(idx)
102
+ __internal_bump_owner_style_generation__
75
103
  nil
76
104
  end
77
105
 
106
+ # `addRule(selector, style, index)` — the legacy IE-era editing API (still
107
+ # in CSSOM). Builds `selector { style }`, inserts it (default: at the end),
108
+ # and returns -1. Omitted selector/style stringify to "undefined", matching
109
+ # the spec's coercion.
110
+ def add_rule(selector = nil, style = nil, index = nil)
111
+ selector = selector.nil? ? "undefined" : selector.to_s
112
+ style = style.nil? ? "undefined" : style.to_s
113
+ idx = index.nil? ? @css_rules.length : index.to_i
114
+ insert_rule("#{selector} { #{style} }", idx)
115
+ -1
116
+ end
117
+
118
+ # `removeRule(index = 0)` — the legacy alias for deleteRule (its index
119
+ # defaults to 0, unlike deleteRule's required argument).
120
+ def remove_rule(index = 0)
121
+ delete_rule(index.nil? ? 0 : index)
122
+ end
123
+
78
124
  # `replaceSync(text)` — replace all rules with a single rule blob
79
125
  # (no parsing — we keep it as one opaque entry).
80
126
  def replace_sync(text)
81
127
  @css_rules.__internal_clear__
82
- return nil if text.to_s.empty?
83
-
84
- @css_rules.__internal_insert__(0, CSSRule.new(text.to_s, self))
128
+ @css_rules.__internal_insert__(0, CSSRule.new(text.to_s, self)) unless text.to_s.empty?
129
+ __internal_bump_owner_style_generation__
85
130
  nil
86
131
  end
87
132
 
@@ -113,6 +158,8 @@ module Dommy
113
158
  parent_style_sheet
114
159
  when "ownerRule"
115
160
  owner_rule
161
+ else
162
+ Bridge::ABSENT
116
163
  end
117
164
  end
118
165
 
@@ -120,25 +167,48 @@ module Dommy
120
167
  case key
121
168
  when "disabled"
122
169
  self.disabled = value
170
+ else
171
+ return Bridge::UNHANDLED
123
172
  end
124
173
 
125
174
  nil
126
175
  end
127
176
 
128
177
  include Bridge::Methods
129
- js_methods %w[insertRule deleteRule replaceSync replace]
178
+ js_methods %w[insertRule deleteRule addRule removeRule replaceSync replace]
130
179
  def __js_call__(method, args)
131
180
  case method
132
181
  when "insertRule"
133
182
  insert_rule(args[0], args[1])
134
183
  when "deleteRule"
135
184
  delete_rule(args[0])
185
+ when "addRule"
186
+ add_rule(args[0], args[1], args[2])
187
+ when "removeRule"
188
+ remove_rule(args[0])
136
189
  when "replaceSync"
137
190
  replace_sync(args[0])
138
191
  when "replace"
139
192
  replace(args[0])
140
193
  end
141
194
  end
195
+
196
+ # A child CSSRule whose text changed (e.g. `rule.style.color = ...`)
197
+ # invalidates the owner document's computed style the same way
198
+ # insertRule/deleteRule do — its rebuilt cssText feeds `cascade_text`.
199
+ def __internal_notify_rule_changed__
200
+ __internal_bump_owner_style_generation__
201
+ end
202
+
203
+ private
204
+
205
+ # CSSOM mutations must invalidate the owner document's computed-style
206
+ # cache — the rule index re-reads `cascade_text` on the next lookup.
207
+ def __internal_bump_owner_style_generation__
208
+ doc = @owner_node.respond_to?(:owner_document) ? @owner_node.owner_document : nil
209
+ doc.__internal_bump_style_generation__ if doc.respond_to?(:__internal_bump_style_generation__)
210
+ nil
211
+ end
142
212
  end
143
213
 
144
214
  # `CSSRuleList` — indexed list of CSSRule, returned by
@@ -208,10 +278,160 @@ module Dommy
208
278
  end
209
279
  end
210
280
 
211
- # `CSSRule` — opaque wrapper over the raw rule text. Real engines
212
- # have a subclass hierarchy (CSSStyleRule, CSSMediaRule, etc.), but
213
- # without a CSS parser we keep one minimal type that round-trips
214
- # the source text.
281
+ # `CSSStyleRule#style` — a live, mutable CSSStyleDeclaration backed by a
282
+ # rule's declaration block. Reads come from the parsed block; every write
283
+ # reserializes the block and hands it back to the owning CSSRule, which
284
+ # rebuilds its cssText and invalidates the document's computed-style cache.
285
+ class RuleStyleDeclaration
286
+ include Enumerable
287
+
288
+ def initialize(rule, body_text)
289
+ @rule = rule
290
+ @props = parse(body_text)
291
+ end
292
+
293
+ def get_property_value(name)
294
+ entry = @props[name.to_s]
295
+ entry ? entry[:value] : ""
296
+ end
297
+
298
+ def get_property_priority(name)
299
+ entry = @props[name.to_s]
300
+ entry ? entry[:priority] : ""
301
+ end
302
+
303
+ def set_property(name, value, priority = nil)
304
+ key = name.to_s
305
+ if value.nil? || value.to_s.empty?
306
+ @props.delete(key)
307
+ else
308
+ important = priority.to_s.downcase == "important" ? "important" : ""
309
+ @props[key] = {value: value.to_s, priority: important}
310
+ end
311
+ flush!
312
+ nil
313
+ end
314
+
315
+ def remove_property(name)
316
+ removed = @props.delete(name.to_s)
317
+ flush!
318
+ removed ? removed[:value] : ""
319
+ end
320
+
321
+ def [](key)
322
+ key.is_a?(Integer) ? @props.keys[key].to_s : get_property_value(key)
323
+ end
324
+
325
+ def []=(name, value)
326
+ set_property(name, value)
327
+ end
328
+
329
+ def length
330
+ @props.size
331
+ end
332
+
333
+ def item(index)
334
+ @props.keys[index.to_i].to_s
335
+ end
336
+
337
+ def css_text
338
+ @props.map do |name, entry|
339
+ important = entry[:priority] == "important" ? " !important" : ""
340
+ "#{name}: #{entry[:value]}#{important};"
341
+ end.join(" ")
342
+ end
343
+
344
+ def css_text=(text)
345
+ @props = parse(text)
346
+ flush!
347
+ end
348
+
349
+ def each(&blk)
350
+ @props.keys.each(&blk)
351
+ end
352
+
353
+ # camelCase / snake_case property accessors (`style.backgroundColor`).
354
+ def method_missing(name, *args)
355
+ str = name.to_s
356
+ if str.end_with?("=")
357
+ set_property(css_name(str[0..-2]), args.first)
358
+ elsif args.empty?
359
+ get_property_value(css_name(str))
360
+ else
361
+ super
362
+ end
363
+ end
364
+
365
+ def respond_to_missing?(_name, _include_private = false)
366
+ true
367
+ end
368
+
369
+ def __js_get__(key)
370
+ case key
371
+ when "cssText" then css_text
372
+ when "length" then length
373
+ when "parentRule" then @rule
374
+ else
375
+ if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
376
+ self[key.to_i]
377
+ else
378
+ get_property_value(css_name(key.to_s))
379
+ end
380
+ end
381
+ end
382
+
383
+ def __js_set__(key, value)
384
+ case key
385
+ when "cssText" then self.css_text = value
386
+ else set_property(css_name(key.to_s), value)
387
+ end
388
+
389
+ nil
390
+ end
391
+
392
+ include Bridge::Methods
393
+ js_methods %w[getPropertyValue getPropertyPriority setProperty removeProperty item]
394
+ def __js_call__(method, args)
395
+ case method
396
+ when "getPropertyValue" then get_property_value(args[0])
397
+ when "getPropertyPriority" then get_property_priority(args[0])
398
+ when "setProperty" then set_property(args[0], args[1], args[2])
399
+ when "removeProperty" then remove_property(args[0])
400
+ when "item" then item(args[0].to_i)
401
+ end
402
+ end
403
+
404
+ private
405
+
406
+ def css_name(name)
407
+ str = name.to_s
408
+ return str if str.start_with?("--")
409
+
410
+ str.include?("_") ? str.tr("_", "-") : str.gsub(/[A-Z]/) { "-#{Regexp.last_match(0).downcase}" }
411
+ end
412
+
413
+ # Parse a declaration block into ordered { name => {value:, priority:} },
414
+ # reusing the cascade's declaration parser (same normalization the cascade
415
+ # sees) so reads agree with computed style.
416
+ def parse(body_text)
417
+ Internal::CSS::Parser.parse_declarations(body_text.to_s).each_with_object({}) do |decl, out|
418
+ out[decl.name] = {value: decl.value, priority: decl.important ? "important" : ""}
419
+ end
420
+ end
421
+
422
+ def flush!
423
+ @rule.__internal_rebuild_from_style__(css_text)
424
+ end
425
+ end
426
+
427
+ # `CSSRule` — one parsed stylesheet rule. `cssText` round-trips the source
428
+ # slice verbatim until the rule is mutated. The CSSOM subclass hierarchy
429
+ # (CSSStyleRule / CSSMediaRule / …) is collapsed into this one class, which
430
+ # exposes the accessors per `type`: `selectorText` + `style` for style
431
+ # rules, `conditionText` + `cssRules` for grouping rules (@media/@supports).
432
+ # The selector text and declaration block are derived lazily with
433
+ # Internal::CSSRuleText (a light scanner; the cascade's correctness still
434
+ # comes from lexbor).
215
435
  class CSSRule
216
436
  STYLE_RULE = 1
217
437
  CHARSET_RULE = 2
@@ -221,6 +441,15 @@ module Dommy
221
441
  PAGE_RULE = 6
222
442
  KEYFRAMES_RULE = 7
223
443
  KEYFRAME_RULE = 8
444
+ SUPPORTS_RULE = 12
445
+
446
+ AT_RULE_TYPES = {
447
+ "media" => MEDIA_RULE, "supports" => SUPPORTS_RULE, "import" => IMPORT_RULE,
448
+ "charset" => CHARSET_RULE, "font-face" => FONT_FACE_RULE, "page" => PAGE_RULE,
449
+ "keyframes" => KEYFRAMES_RULE
450
+ }.freeze
451
+
452
+ GROUPING_TYPES = [MEDIA_RULE, SUPPORTS_RULE].freeze
224
453
 
225
454
  attr_reader :parent_style_sheet
226
455
 
@@ -235,60 +464,257 @@ module Dommy
235
464
 
236
465
  def css_text=(v)
237
466
  @css_text = v.to_s
467
+ invalidate!
238
468
  end
239
469
 
240
- # We don't parse, so report the generic STYLE_RULE type.
241
470
  def type
242
- STYLE_RULE
471
+ keyword = Internal::CSSRuleText.at_keyword(prelude)
472
+ keyword ? AT_RULE_TYPES.fetch(keyword, STYLE_RULE) : STYLE_RULE
473
+ end
474
+
475
+ def style_rule?
476
+ type == STYLE_RULE
477
+ end
478
+
479
+ def grouping?
480
+ GROUPING_TYPES.include?(type)
481
+ end
482
+
483
+ # CSSStyleRule#selectorText — the rule's selector list ("" for at-rules).
484
+ def selector_text
485
+ style_rule? ? prelude : ""
486
+ end
487
+
488
+ def selector_text=(value)
489
+ return unless style_rule?
490
+
491
+ @selector = value.to_s
492
+ rebuild_css_text!
493
+ end
494
+
495
+ # CSSStyleRule#style — a live, mutable declaration block. nil for at-rules
496
+ # (matching the absent `style` member on CSSMediaRule etc.). Writes
497
+ # reserialize cssText and invalidate the document's computed-style cache.
498
+ def style
499
+ return nil unless style_rule?
500
+
501
+ @style ||= RuleStyleDeclaration.new(self, Internal::CSSRuleText.split_rule(@css_text).last)
502
+ end
503
+
504
+ # CSSGroupingRule#cssRules — the nested rules of an @media/@supports block.
505
+ def css_rules
506
+ return nil unless grouping?
507
+
508
+ @css_rules ||= begin
509
+ list = CSSRuleList.new
510
+ Internal::CSSRuleText.split_rules(body).each_with_index do |slice, i|
511
+ list.__internal_insert__(i, CSSRule.new(slice, @parent_style_sheet))
512
+ end
513
+ list
514
+ end
515
+ end
516
+
517
+ # CSSConditionRule#conditionText — the condition text after the at-keyword.
518
+ def condition_text
519
+ grouping? ? prelude.sub(/\A@[-a-z]+/i, "").strip : ""
520
+ end
521
+
522
+ # CSSMediaRule#media — a live MediaList over the @media condition. nil for
523
+ # non-media rules. Mutations rebuild the rule's prelude and reflow the
524
+ # cascade.
525
+ def media
526
+ return nil unless type == MEDIA_RULE
527
+
528
+ @media_list ||= MediaList.new(condition_text, on_change: method(:__internal_set_media__))
243
529
  end
244
530
 
245
531
  def parent_rule
246
532
  nil
247
533
  end
248
534
 
535
+ # Called by RuleStyleDeclaration after a property write: rebuild cssText
536
+ # from the (possibly new) selector and declaration block.
537
+ def __internal_rebuild_from_style__(block_text)
538
+ @selector ||= prelude
539
+ @css_text = block_text.empty? ? "#{@selector} {}" : "#{@selector} { #{block_text} }"
540
+ @parent_style_sheet&.__internal_notify_rule_changed__
541
+ nil
542
+ end
543
+
544
+ # Called by the MediaList when its media text changes: rebuild the @media
545
+ # prelude (keeping the block body) and reflow the cascade.
546
+ def __internal_set_media__(media_text)
547
+ @css_text = "@media #{media_text} { #{body} }"
548
+ @prelude = nil
549
+ @media_list = nil
550
+ @parent_style_sheet&.__internal_notify_rule_changed__
551
+ nil
552
+ end
553
+
249
554
  def __js_get__(key)
250
555
  case key
251
- when "cssText"
252
- @css_text
253
- when "type"
254
- type
255
- when "parentStyleSheet"
256
- @parent_style_sheet
257
- when "parentRule"
258
- parent_rule
259
- when "STYLE_RULE"
260
- STYLE_RULE
261
- when "MEDIA_RULE"
262
- MEDIA_RULE
263
- when "IMPORT_RULE"
264
- IMPORT_RULE
265
- when "FONT_FACE_RULE"
266
- FONT_FACE_RULE
267
- when "PAGE_RULE"
268
- PAGE_RULE
269
- when "KEYFRAMES_RULE"
270
- KEYFRAMES_RULE
271
- when "KEYFRAME_RULE"
272
- KEYFRAME_RULE
273
- when "CHARSET_RULE"
274
- CHARSET_RULE
556
+ when "cssText" then @css_text
557
+ when "type" then type
558
+ when "selectorText" then selector_text
559
+ when "style" then style
560
+ when "cssRules" then css_rules
561
+ when "conditionText" then grouping? ? condition_text : nil
562
+ when "media" then media
563
+ when "parentStyleSheet" then @parent_style_sheet
564
+ when "parentRule" then parent_rule
565
+ when "STYLE_RULE" then STYLE_RULE
566
+ when "MEDIA_RULE" then MEDIA_RULE
567
+ when "IMPORT_RULE" then IMPORT_RULE
568
+ when "FONT_FACE_RULE" then FONT_FACE_RULE
569
+ when "PAGE_RULE" then PAGE_RULE
570
+ when "KEYFRAMES_RULE" then KEYFRAMES_RULE
571
+ when "KEYFRAME_RULE" then KEYFRAME_RULE
572
+ when "SUPPORTS_RULE" then SUPPORTS_RULE
573
+ when "CHARSET_RULE" then CHARSET_RULE
574
+ else
575
+ Bridge::ABSENT
275
576
  end
276
577
  end
277
578
 
278
579
  def __js_set__(key, value)
279
580
  case key
280
- when "cssText"
281
- self.css_text = value
581
+ when "cssText" then self.css_text = value
582
+ when "selectorText" then self.selector_text = value
583
+ when "media"
584
+ # CSSMediaRule#media is settable with a media-text string.
585
+ __internal_set_media__(value.to_s) if type == MEDIA_RULE
586
+ else
587
+ # Signal "not a host property" so the bridge keeps the assignment as a
588
+ # JS-side expando (WebIDL platform objects allow expandos; WPT's
589
+ # [SameObject] tests stash a marker on cssRules[i] and read it back).
590
+ return Bridge::UNHANDLED
591
+ end
592
+
593
+ nil
594
+ end
595
+
596
+ private
597
+
598
+ # The rule prelude (selector list or at-rule keyword + condition),
599
+ # memoized; recomputed after css_text changes.
600
+ def prelude
601
+ @prelude ||= Internal::CSSRuleText.split_rule(@css_text).first
602
+ end
603
+
604
+ # The block body (text between the outermost braces), or "" when absent.
605
+ def body
606
+ Internal::CSSRuleText.split_rule(@css_text).last.to_s
607
+ end
608
+
609
+ def rebuild_css_text!
610
+ @css_text = body.strip.empty? ? "#{@selector} {}" : "#{@selector} { #{body.strip} }"
611
+ @prelude = nil
612
+ @parent_style_sheet&.__internal_notify_rule_changed__
613
+ end
614
+
615
+ # Drop derived state after cssText is replaced wholesale.
616
+ def invalidate!
617
+ @prelude = nil
618
+ @selector = nil
619
+ @style = nil
620
+ @css_rules = nil
621
+ @media_list = nil
622
+ end
623
+ end
624
+
625
+ # `MediaList` — the CSSOM list of comma-separated media queries behind
626
+ # `CSSMediaRule#media` (and `<style>`/`<link>`/`CSSStyleSheet#media`).
627
+ # Indexed, with mediaText/append/delete editing; `on_change` (optional) is
628
+ # called with the new media text after any mutation so the owner can persist
629
+ # it.
630
+ class MediaList
631
+ def initialize(media_text = "", on_change: nil)
632
+ @items = parse(media_text)
633
+ @on_change = on_change
634
+ end
635
+
636
+ def length
637
+ @items.length
638
+ end
639
+
640
+ def item(index)
641
+ i = index.to_i
642
+ i.negative? || i >= @items.length ? nil : @items[i]
643
+ end
644
+
645
+ def media_text
646
+ @items.join(", ")
647
+ end
648
+
649
+ def media_text=(text)
650
+ @items = parse(text)
651
+ notify
652
+ end
653
+
654
+ # css-mediaqueries: appendMedium is a no-op when the medium is already
655
+ # present (case-insensitively); deleteMedium removes every match.
656
+ def append_medium(medium)
657
+ medium = medium.to_s.strip
658
+ return if medium.empty? || @items.any? { |existing| existing.casecmp(medium).zero? }
659
+
660
+ @items << medium
661
+ notify
662
+ end
663
+
664
+ def delete_medium(medium)
665
+ medium = medium.to_s.strip
666
+ @items.reject! { |existing| existing.casecmp(medium).zero? }
667
+ notify
668
+ end
669
+
670
+ def to_s
671
+ media_text
672
+ end
673
+
674
+ def __js_get__(key)
675
+ case key
676
+ when "length" then length
677
+ when "mediaText" then media_text
678
+ else
679
+ item(key.to_i) if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
282
680
  end
681
+ end
283
682
 
683
+ def __js_set__(key, value)
684
+ return Bridge::UNHANDLED unless key == "mediaText"
685
+
686
+ # [LegacyNullToEmptyString]: `media.mediaText = null` clears it.
687
+ self.media_text = value.nil? ? "" : value
284
688
  nil
285
689
  end
690
+
691
+ include Bridge::Methods
692
+ js_methods %w[item appendMedium deleteMedium toString]
693
+ def __js_call__(method, args)
694
+ case method
695
+ when "item" then item(args[0])
696
+ when "appendMedium" then append_medium(args[0])
697
+ when "deleteMedium" then delete_medium(args[0])
698
+ when "toString" then to_s
699
+ end
700
+ end
701
+
702
+ private
703
+
704
+ def parse(text)
705
+ text.to_s.split(",").map(&:strip).reject(&:empty?)
706
+ end
707
+
708
+ def notify
709
+ @on_change&.call(media_text)
710
+ end
286
711
  end
287
712
 
288
713
  # `window.CSS` namespace object — `escape()` for safe selector building
289
- # (used by Turbo and friends) and a `supports()` stub (no CSS engine).
714
+ # (used by Turbo and friends) and `supports()` backed by the @supports
715
+ # condition evaluator.
290
716
  class CSSNamespace
291
- def __js_get__(_key) = nil
717
+ def __js_get__(_key) = Bridge::ABSENT # method-only; any property read is absent
292
718
  def __js_set__(_key, _value) = Bridge::UNHANDLED
293
719
 
294
720
  include Bridge::Methods
@@ -298,7 +724,18 @@ module Dommy
298
724
  when "escape"
299
725
  self.class.escape(args[0])
300
726
  when "supports"
301
- false
727
+ supports?(args)
728
+ end
729
+ end
730
+
731
+ # CSS.supports(property, value) checks one declaration; CSS.supports(
732
+ # conditionText) evaluates a full <supports-condition>. Both go through the
733
+ # same optimistic evaluator the cascade uses for @supports.
734
+ def supports?(args)
735
+ if args.length >= 2 && !args[1].nil?
736
+ Internal::CSS::Supports.supports_declaration?(args[0], args[1])
737
+ else
738
+ Internal::CSS::Supports.match?(args[0])
302
739
  end
303
740
  end
304
741
 
@@ -97,7 +97,7 @@ module Dommy
97
97
  end
98
98
 
99
99
  def __js_get__(_key)
100
- nil
100
+ Bridge::ABSENT # method-only registry; any property read is absent
101
101
  end
102
102
 
103
103
  include Bridge::Methods
@@ -130,7 +130,7 @@ module Dommy
130
130
  # Match by tag name rather than interpolating `name` into a CSS selector:
131
131
  # a spec-valid custom element name may contain "." (a CSS class selector
132
132
  # char) or other metacharacters, which would corrupt the query.
133
- doc.nokogiri_doc.css("*").each do |nk|
133
+ doc.backend_doc.css("*").each do |nk|
134
134
  next unless nk.name == name
135
135
 
136
136
  doc.__internal_reset_wrapper__(nk)
@@ -55,6 +55,8 @@ module Dommy
55
55
  @drop_effect
56
56
  when "effectAllowed"
57
57
  @effect_allowed
58
+ else
59
+ Bridge::ABSENT
58
60
  end
59
61
  end
60
62