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
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module CSS
6
+ # Normalizes CSS color values into the serialization browsers use for
7
+ # `getComputedStyle` — `rgb(r, g, b)` / `rgba(r, g, b, a)`.
8
+ #
9
+ # Supported inputs: the 148 CSS Color Module Level 4 named colors,
10
+ # `transparent`, `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa` hex
11
+ # notations, and `rgb()` / `rgba()` functional notation. Anything else
12
+ # (hsl(), var(), …) is passed through unchanged.
13
+ module Color
14
+ module_function
15
+
16
+ # CSS Color Module Level 4 named colors (148 keywords) => [r, g, b].
17
+ NAMED = {
18
+ "aliceblue" => [240, 248, 255],
19
+ "antiquewhite" => [250, 235, 215],
20
+ "aqua" => [0, 255, 255],
21
+ "aquamarine" => [127, 255, 212],
22
+ "azure" => [240, 255, 255],
23
+ "beige" => [245, 245, 220],
24
+ "bisque" => [255, 228, 196],
25
+ "black" => [0, 0, 0],
26
+ "blanchedalmond" => [255, 235, 205],
27
+ "blue" => [0, 0, 255],
28
+ "blueviolet" => [138, 43, 226],
29
+ "brown" => [165, 42, 42],
30
+ "burlywood" => [222, 184, 135],
31
+ "cadetblue" => [95, 158, 160],
32
+ "chartreuse" => [127, 255, 0],
33
+ "chocolate" => [210, 105, 30],
34
+ "coral" => [255, 127, 80],
35
+ "cornflowerblue" => [100, 149, 237],
36
+ "cornsilk" => [255, 248, 220],
37
+ "crimson" => [220, 20, 60],
38
+ "cyan" => [0, 255, 255],
39
+ "darkblue" => [0, 0, 139],
40
+ "darkcyan" => [0, 139, 139],
41
+ "darkgoldenrod" => [184, 134, 11],
42
+ "darkgray" => [169, 169, 169],
43
+ "darkgreen" => [0, 100, 0],
44
+ "darkgrey" => [169, 169, 169],
45
+ "darkkhaki" => [189, 183, 107],
46
+ "darkmagenta" => [139, 0, 139],
47
+ "darkolivegreen" => [85, 107, 47],
48
+ "darkorange" => [255, 140, 0],
49
+ "darkorchid" => [153, 50, 204],
50
+ "darkred" => [139, 0, 0],
51
+ "darksalmon" => [233, 150, 122],
52
+ "darkseagreen" => [143, 188, 143],
53
+ "darkslateblue" => [72, 61, 139],
54
+ "darkslategray" => [47, 79, 79],
55
+ "darkslategrey" => [47, 79, 79],
56
+ "darkturquoise" => [0, 206, 209],
57
+ "darkviolet" => [148, 0, 211],
58
+ "deeppink" => [255, 20, 147],
59
+ "deepskyblue" => [0, 191, 255],
60
+ "dimgray" => [105, 105, 105],
61
+ "dimgrey" => [105, 105, 105],
62
+ "dodgerblue" => [30, 144, 255],
63
+ "firebrick" => [178, 34, 34],
64
+ "floralwhite" => [255, 250, 240],
65
+ "forestgreen" => [34, 139, 34],
66
+ "fuchsia" => [255, 0, 255],
67
+ "gainsboro" => [220, 220, 220],
68
+ "ghostwhite" => [248, 248, 255],
69
+ "gold" => [255, 215, 0],
70
+ "goldenrod" => [218, 165, 32],
71
+ "gray" => [128, 128, 128],
72
+ "green" => [0, 128, 0],
73
+ "greenyellow" => [173, 255, 47],
74
+ "grey" => [128, 128, 128],
75
+ "honeydew" => [240, 255, 240],
76
+ "hotpink" => [255, 105, 180],
77
+ "indianred" => [205, 92, 92],
78
+ "indigo" => [75, 0, 130],
79
+ "ivory" => [255, 255, 240],
80
+ "khaki" => [240, 230, 140],
81
+ "lavender" => [230, 230, 250],
82
+ "lavenderblush" => [255, 240, 245],
83
+ "lawngreen" => [124, 252, 0],
84
+ "lemonchiffon" => [255, 250, 205],
85
+ "lightblue" => [173, 216, 230],
86
+ "lightcoral" => [240, 128, 128],
87
+ "lightcyan" => [224, 255, 255],
88
+ "lightgoldenrodyellow" => [250, 250, 210],
89
+ "lightgray" => [211, 211, 211],
90
+ "lightgreen" => [144, 238, 144],
91
+ "lightgrey" => [211, 211, 211],
92
+ "lightpink" => [255, 182, 193],
93
+ "lightsalmon" => [255, 160, 122],
94
+ "lightseagreen" => [32, 178, 170],
95
+ "lightskyblue" => [135, 206, 250],
96
+ "lightslategray" => [119, 136, 153],
97
+ "lightslategrey" => [119, 136, 153],
98
+ "lightsteelblue" => [176, 196, 222],
99
+ "lightyellow" => [255, 255, 224],
100
+ "lime" => [0, 255, 0],
101
+ "limegreen" => [50, 205, 50],
102
+ "linen" => [250, 240, 230],
103
+ "magenta" => [255, 0, 255],
104
+ "maroon" => [128, 0, 0],
105
+ "mediumaquamarine" => [102, 205, 170],
106
+ "mediumblue" => [0, 0, 205],
107
+ "mediumorchid" => [186, 85, 211],
108
+ "mediumpurple" => [147, 112, 219],
109
+ "mediumseagreen" => [60, 179, 113],
110
+ "mediumslateblue" => [123, 104, 238],
111
+ "mediumspringgreen" => [0, 250, 154],
112
+ "mediumturquoise" => [72, 209, 204],
113
+ "mediumvioletred" => [199, 21, 133],
114
+ "midnightblue" => [25, 25, 112],
115
+ "mintcream" => [245, 255, 250],
116
+ "mistyrose" => [255, 228, 225],
117
+ "moccasin" => [255, 228, 181],
118
+ "navajowhite" => [255, 222, 173],
119
+ "navy" => [0, 0, 128],
120
+ "oldlace" => [253, 245, 230],
121
+ "olive" => [128, 128, 0],
122
+ "olivedrab" => [107, 142, 35],
123
+ "orange" => [255, 165, 0],
124
+ "orangered" => [255, 69, 0],
125
+ "orchid" => [218, 112, 214],
126
+ "palegoldenrod" => [238, 232, 170],
127
+ "palegreen" => [152, 251, 152],
128
+ "paleturquoise" => [175, 238, 238],
129
+ "palevioletred" => [219, 112, 147],
130
+ "papayawhip" => [255, 239, 213],
131
+ "peachpuff" => [255, 218, 185],
132
+ "peru" => [205, 133, 63],
133
+ "pink" => [255, 192, 203],
134
+ "plum" => [221, 160, 221],
135
+ "powderblue" => [176, 224, 230],
136
+ "purple" => [128, 0, 128],
137
+ "rebeccapurple" => [102, 51, 153],
138
+ "red" => [255, 0, 0],
139
+ "rosybrown" => [188, 143, 143],
140
+ "royalblue" => [65, 105, 225],
141
+ "saddlebrown" => [139, 69, 19],
142
+ "salmon" => [250, 128, 114],
143
+ "sandybrown" => [244, 164, 96],
144
+ "seagreen" => [46, 139, 87],
145
+ "seashell" => [255, 245, 238],
146
+ "sienna" => [160, 82, 45],
147
+ "silver" => [192, 192, 192],
148
+ "skyblue" => [135, 206, 235],
149
+ "slateblue" => [106, 90, 205],
150
+ "slategray" => [112, 128, 144],
151
+ "slategrey" => [112, 128, 144],
152
+ "snow" => [255, 250, 250],
153
+ "springgreen" => [0, 255, 127],
154
+ "steelblue" => [70, 130, 180],
155
+ "tan" => [210, 180, 140],
156
+ "teal" => [0, 128, 128],
157
+ "thistle" => [216, 191, 216],
158
+ "tomato" => [255, 99, 71],
159
+ "turquoise" => [64, 224, 208],
160
+ "violet" => [238, 130, 238],
161
+ "wheat" => [245, 222, 179],
162
+ "white" => [255, 255, 255],
163
+ "whitesmoke" => [245, 245, 245],
164
+ "yellow" => [255, 255, 0],
165
+ "yellowgreen" => [154, 205, 50]
166
+ }.freeze
167
+
168
+ HEX_PATTERN = /\A#(\h{3}|\h{4}|\h{6}|\h{8})\z/
169
+ private_constant :HEX_PATTERN
170
+
171
+ # rgb()/rgba()/hsl()/hsla() in either legacy comma syntax or modern
172
+ # space syntax (`rgb(R G B / A)`), with integer or percentage channels.
173
+ COLOR_FN_PATTERN = /\A(rgba?|hsla?)\((.*)\)\z/im
174
+ private_constant :COLOR_FN_PATTERN
175
+
176
+ # Normalizes +value+ to the computed-style serialization.
177
+ # Unrecognized values are returned as-is (never raises).
178
+ #
179
+ # @param value [String] a CSS color value, e.g. "Red", "#0f0", "rgb(0,0,0)"
180
+ # @return [String] "rgb(r, g, b)", "rgba(r, g, b, a)", or +value+ itself
181
+ def normalize(value)
182
+ stripped = value.strip
183
+ lower = stripped.downcase
184
+
185
+ return "rgba(0, 0, 0, 0)" if lower == "transparent"
186
+
187
+ if (rgb = NAMED[lower])
188
+ return serialize(*rgb)
189
+ end
190
+
191
+ if (match = HEX_PATTERN.match(lower))
192
+ return from_hex(match[1])
193
+ end
194
+
195
+ if (match = COLOR_FN_PATTERN.match(stripped))
196
+ return normalize_function(match[1].downcase, match[2]) || value
197
+ end
198
+
199
+ value
200
+ end
201
+
202
+ # Extracts the first color token from a compound value (used by
203
+ # shorthand expansion, e.g. `background: red url(x)`). Returns the
204
+ # normalized color, or nil when no recognizable color is present.
205
+ #
206
+ # @param value [String] a whitespace-separated CSS shorthand value
207
+ # @return [String, nil]
208
+ def extract(value)
209
+ top_level_tokens(value).each do |token|
210
+ lower = token.downcase
211
+ if token.include?("(")
212
+ # Function tokens are skipped wholesale (url(#abc),
213
+ # linear-gradient(red, blue), …) — except rgb()/rgba(),
214
+ # which are themselves colors.
215
+ return normalize(token) if lower.start_with?("rgb(", "rgba(")
216
+ elsif HEX_PATTERN.match?(lower) || lower == "transparent" || NAMED.key?(lower)
217
+ return normalize(token)
218
+ end
219
+ end
220
+ nil
221
+ end
222
+
223
+ # Splits +value+ into top-level tokens: whitespace separates tokens
224
+ # only outside parentheses, so a function and its arguments (e.g.
225
+ # `linear-gradient(to right, red)`) stay one token.
226
+ def top_level_tokens(value)
227
+ tokens = []
228
+ current = +""
229
+ depth = 0
230
+ value.each_char do |char|
231
+ case char
232
+ when "("
233
+ depth += 1
234
+ current << char
235
+ when ")"
236
+ depth -= 1 if depth.positive?
237
+ current << char
238
+ when /\s/
239
+ if depth.positive?
240
+ current << char
241
+ elsif !current.empty?
242
+ tokens << current
243
+ current = +""
244
+ end
245
+ else
246
+ current << char
247
+ end
248
+ end
249
+ tokens << current unless current.empty?
250
+ tokens
251
+ end
252
+
253
+ # Normalize an rgb()/hsl() function body to rgb()/rgba(). Returns nil
254
+ # when the channels don't parse (the caller keeps the original text).
255
+ def normalize_function(name, args)
256
+ channels, alpha = split_color_args(args)
257
+ return nil unless channels.length == 3
258
+
259
+ rgb =
260
+ if name.start_with?("rgb")
261
+ channels.map { |channel| rgb_channel(channel) }
262
+ else
263
+ hsl_to_rgb(hue(channels[0]), percentage(channels[1]), percentage(channels[2]))
264
+ end
265
+ return nil if rgb.any?(&:nil?)
266
+
267
+ serialize(*rgb, alpha)
268
+ end
269
+
270
+ # [[c1, c2, c3], alpha_or_nil]. Channels are comma- or space-separated;
271
+ # a `/` introduces the alpha (modern syntax), as does a 4th legacy comma
272
+ # value.
273
+ def split_color_args(args)
274
+ args = args.strip
275
+ if args.include?("/")
276
+ main, alpha = args.split("/", 2)
277
+ [main.strip.split(/[\s,]+/), parse_alpha(alpha.strip)]
278
+ else
279
+ parts = args.split(/[\s,]+/)
280
+ parts.length == 4 ? [parts[0, 3], parse_alpha(parts[3])] : [parts, nil]
281
+ end
282
+ end
283
+
284
+ # The css-color-4 `none` keyword marks a missing component; in the
285
+ # legacy rgb()/rgba() serialization a computed color uses 0 for it.
286
+ def none?(text) = text.casecmp("none").zero?
287
+
288
+ def rgb_channel(channel)
289
+ return 0 if none?(channel)
290
+
291
+ if channel.end_with?("%")
292
+ value = Float(channel[0..-2]) / 100.0 * 255
293
+ else
294
+ value = Float(channel)
295
+ end
296
+ value.round.clamp(0, 255)
297
+ rescue ArgumentError, TypeError
298
+ nil
299
+ end
300
+
301
+ def parse_alpha(text)
302
+ return 0.0 if none?(text)
303
+
304
+ value = text.end_with?("%") ? Float(text[0..-2]) / 100.0 : Float(text)
305
+ value.clamp(0.0, 1.0)
306
+ rescue ArgumentError, TypeError
307
+ nil
308
+ end
309
+
310
+ # An <hue>: a bare number or <angle> (deg/grad/rad/turn), wrapped into
311
+ # [0, 360). nil when unparseable.
312
+ def hue(text)
313
+ text = text.downcase
314
+ return 0.0 if none?(text)
315
+
316
+ degrees =
317
+ case text
318
+ when /\A(-?\d*\.?\d+)deg\z/ then Float(Regexp.last_match(1))
319
+ when /\A(-?\d*\.?\d+)grad\z/ then Float(Regexp.last_match(1)) * 0.9
320
+ when /\A(-?\d*\.?\d+)rad\z/ then Float(Regexp.last_match(1)) * 180 / Math::PI
321
+ when /\A(-?\d*\.?\d+)turn\z/ then Float(Regexp.last_match(1)) * 360
322
+ when /\A-?\d*\.?\d+\z/ then Float(text)
323
+ end
324
+ degrees && degrees % 360
325
+ end
326
+
327
+ def percentage(text)
328
+ return 0.0 if none?(text)
329
+ return nil unless text.end_with?("%")
330
+
331
+ (Float(text[0..-2]) / 100.0).clamp(0.0, 1.0)
332
+ rescue ArgumentError, TypeError
333
+ nil
334
+ end
335
+
336
+ # HSL -> [r, g, b] (0..255). h in degrees, s/l in 0..1.
337
+ def hsl_to_rgb(h, s, l)
338
+ return [nil] if h.nil? || s.nil? || l.nil?
339
+
340
+ chroma = (1 - (2 * l - 1).abs) * s
341
+ secondary = chroma * (1 - ((h / 60.0) % 2 - 1).abs)
342
+ match = l - chroma / 2
343
+ r, g, b =
344
+ case h
345
+ when 0...60 then [chroma, secondary, 0]
346
+ when 60...120 then [secondary, chroma, 0]
347
+ when 120...180 then [0, chroma, secondary]
348
+ when 180...240 then [0, secondary, chroma]
349
+ when 240...300 then [secondary, 0, chroma]
350
+ else [chroma, 0, secondary]
351
+ end
352
+ [r, g, b].map { |channel| ((channel + match) * 255).round }
353
+ end
354
+
355
+ # Expands a 3/4/6/8-digit hex body into the rgb()/rgba() form.
356
+ def from_hex(hex)
357
+ hex = hex.chars.map { |c| c * 2 }.join if hex.length <= 4
358
+ r, g, b, a = hex.scan(/\h{2}/).map { |pair| pair.to_i(16) }
359
+ serialize(r, g, b, a && a / 255.0)
360
+ end
361
+
362
+ # Builds "rgb(r, g, b)" or, when alpha is present and not 1,
363
+ # "rgba(r, g, b, a)".
364
+ def serialize(r, g, b, alpha = nil)
365
+ return "rgb(#{r}, #{g}, #{b})" if alpha.nil? || alpha == 1
366
+
367
+ "rgba(#{r}, #{g}, #{b}, #{format_alpha(alpha)})"
368
+ end
369
+
370
+ # Formats an alpha component: round to 5 decimals, drop trailing
371
+ # zeros, and serialize integral values without a decimal point.
372
+ def format_alpha(alpha)
373
+ rounded = alpha.round(5)
374
+ return rounded.to_i.to_s if rounded == rounded.to_i
375
+
376
+ rounded.to_s
377
+ end
378
+ end
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module CSS
6
+ # The read-only CSSStyleDeclaration that getComputedStyle returns.
7
+ # CSSOM's "live object" behavior is approximated by recomputing lazily:
8
+ # every read goes through Cascade.computed_style, whose per-generation
9
+ # memo makes repeated reads cheap while DOM mutations show up on the
10
+ # next read. ::before/::after expose cascaded computed declarations even
11
+ # though Dommy renders no generated boxes.
12
+ class ComputedStyleDeclaration
13
+ EMPTY = {}.freeze
14
+
15
+ # Pseudo-elements getComputedStyle exposes a cascaded computed
16
+ # declaration for (no box generation, just the cascade). Unknown
17
+ # pseudo-elements yield an empty declaration, as browsers do.
18
+ KNOWN_PSEUDO_ELEMENTS = %w[
19
+ before after first-line first-letter marker selection placeholder backdrop
20
+ ].freeze
21
+
22
+ def initialize(element, pseudo_element: nil)
23
+ @element = element
24
+ @pseudo_element = pseudo_element
25
+ end
26
+
27
+ def get_property_value(name)
28
+ key = name.to_s
29
+ # Custom properties are case-sensitive; everything else isn't.
30
+ key = key.downcase unless key.start_with?("--")
31
+ styles[key].to_s
32
+ end
33
+
34
+ def [](name)
35
+ get_property_value(name)
36
+ end
37
+
38
+ def length
39
+ styles.size
40
+ end
41
+
42
+ def item(index)
43
+ styles.keys[index].to_s
44
+ end
45
+
46
+ def css_text
47
+ styles.map { |name, value| "#{name}: #{value};" }.join(" ")
48
+ end
49
+
50
+ def to_h
51
+ styles.dup
52
+ end
53
+
54
+ def set_property(*, **)
55
+ raise_read_only
56
+ end
57
+
58
+ def remove_property(*)
59
+ raise_read_only
60
+ end
61
+
62
+ # --- JS bridge ---
63
+
64
+ def __js_get__(key)
65
+ case key
66
+ when "cssText" then css_text
67
+ when "length" then length
68
+ when "parentRule" then nil
69
+ else
70
+ # An unset/unknown property reads as "" per CSSStyleDeclaration
71
+ # (a detached element's whole declaration is empty), not undefined.
72
+ styles[camel_to_kebab(key)].to_s
73
+ end
74
+ end
75
+
76
+ def __js_set__(_key, _value)
77
+ Bridge::UNHANDLED
78
+ end
79
+
80
+ include Bridge::Methods
81
+ js_methods %w[getPropertyValue getPropertyPriority item setProperty removeProperty]
82
+ def __js_call__(method, args)
83
+ case method
84
+ when "getPropertyValue"
85
+ get_property_value(args[0])
86
+ when "getPropertyPriority"
87
+ ""
88
+ when "item"
89
+ item(args[0].to_i)
90
+ when "setProperty", "removeProperty"
91
+ raise_read_only
92
+ end
93
+ end
94
+
95
+ # Ruby-side property readers, e.g. `computed.background_color` or
96
+ # `computed.backgroundColor`.
97
+ def method_missing(name, *args)
98
+ raise_read_only if name.to_s.end_with?("=")
99
+ return super unless args.empty?
100
+
101
+ styles.fetch(camel_to_kebab(name.to_s)) { return super }.to_s
102
+ end
103
+
104
+ def respond_to_missing?(name, include_private = false)
105
+ styles.key?(camel_to_kebab(name.to_s)) || super
106
+ end
107
+
108
+ private
109
+
110
+ def styles
111
+ if @pseudo_element
112
+ name = @pseudo_element.to_s.delete_prefix("::").delete_prefix(":")
113
+ return EMPTY unless KNOWN_PSEUDO_ELEMENTS.include?(name)
114
+ end
115
+
116
+ Cascade.computed_style(@element, pseudo_element: @pseudo_element)
117
+ end
118
+
119
+ def camel_to_kebab(name)
120
+ name.to_s.tr("_", "-").gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
121
+ end
122
+
123
+ def raise_read_only
124
+ raise DOMException::NoModificationAllowedError,
125
+ "Cannot modify a computed style declaration"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end