dommy 0.14.0 → 0.16.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -1
  3. data/lib/dommy/attr.rb +56 -32
  4. data/lib/dommy/backend.rb +251 -208
  5. data/lib/dommy/blob.rb +24 -0
  6. data/lib/dommy/bridge/methods.rb +12 -2
  7. data/lib/dommy/bridge/values.rb +29 -2
  8. data/lib/dommy/bridge/wire_tags.rb +8 -4
  9. data/lib/dommy/browser.rb +328 -53
  10. data/lib/dommy/callable_invoker.rb +27 -31
  11. data/lib/dommy/character_data.rb +21 -28
  12. data/lib/dommy/class_list.rb +55 -23
  13. data/lib/dommy/cookie_jar.rb +408 -0
  14. data/lib/dommy/cookie_store.rb +104 -49
  15. data/lib/dommy/css.rb +37 -8
  16. data/lib/dommy/custom_elements.rb +360 -116
  17. data/lib/dommy/data_transfer.rb +22 -2
  18. data/lib/dommy/document.rb +1258 -346
  19. data/lib/dommy/dom_exception.rb +6 -0
  20. data/lib/dommy/dom_parser.rb +46 -14
  21. data/lib/dommy/dom_string_list.rb +36 -0
  22. data/lib/dommy/element.rb +408 -569
  23. data/lib/dommy/element_internals.rb +215 -0
  24. data/lib/dommy/encodings.rb +10 -0
  25. data/lib/dommy/event.rb +382 -148
  26. data/lib/dommy/event_source.rb +8 -34
  27. data/lib/dommy/fetch.rb +8 -12
  28. data/lib/dommy/file_reader.rb +5 -29
  29. data/lib/dommy/form_data.rb +53 -16
  30. data/lib/dommy/form_entry_list.rb +107 -138
  31. data/lib/dommy/fragment.rb +14 -16
  32. data/lib/dommy/history.rb +247 -90
  33. data/lib/dommy/html_canvas_element.rb +15 -35
  34. data/lib/dommy/html_collection.rb +76 -50
  35. data/lib/dommy/html_elements/base.rb +377 -71
  36. data/lib/dommy/html_elements/embedded.rb +350 -106
  37. data/lib/dommy/html_elements/forms/form.rb +424 -383
  38. data/lib/dommy/html_elements/forms/input.rb +351 -189
  39. data/lib/dommy/html_elements/forms/select.rb +97 -81
  40. data/lib/dommy/html_elements/interactive.rb +285 -72
  41. data/lib/dommy/html_elements/media.rb +73 -32
  42. data/lib/dommy/html_elements/metadata.rb +226 -78
  43. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  44. data/lib/dommy/html_elements/tables.rb +20 -42
  45. data/lib/dommy/html_elements.rb +28 -3
  46. data/lib/dommy/hyperlink_utils.rb +19 -8
  47. data/lib/dommy/interaction/drag_and_drop.rb +187 -0
  48. data/lib/dommy/interaction/event_synthesis.rb +182 -24
  49. data/lib/dommy/interaction/field_interactor.rb +18 -6
  50. data/lib/dommy/interaction/form_submission.rb +12 -6
  51. data/lib/dommy/interaction/key_sender.rb +102 -17
  52. data/lib/dommy/interaction.rb +1 -0
  53. data/lib/dommy/internal/accessibility_tree.rb +19 -7
  54. data/lib/dommy/internal/accessibility_visibility.rb +2 -2
  55. data/lib/dommy/internal/accessible_description.rb +3 -3
  56. data/lib/dommy/internal/accessible_name.rb +33 -45
  57. data/lib/dommy/internal/an_plus_b.rb +125 -0
  58. data/lib/dommy/internal/aria_role.rb +27 -18
  59. data/lib/dommy/internal/aria_state.rb +7 -7
  60. data/lib/dommy/internal/autofill.rb +2 -2
  61. data/lib/dommy/internal/backend_prefilter.rb +21 -18
  62. data/lib/dommy/internal/ce_reactions.rb +285 -0
  63. data/lib/dommy/internal/ce_reactions_table.rb +296 -0
  64. data/lib/dommy/internal/child_list.rb +39 -0
  65. data/lib/dommy/internal/child_navigable.rb +155 -0
  66. data/lib/dommy/internal/child_node.rb +44 -41
  67. data/lib/dommy/internal/close_watcher_manager.rb +238 -0
  68. data/lib/dommy/internal/complex_match.rb +129 -0
  69. data/lib/dommy/internal/constraint_validation.rb +130 -0
  70. data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
  71. data/lib/dommy/internal/css/cascade.rb +7 -7
  72. data/lib/dommy/internal/css/cascaded_declarations.rb +14 -20
  73. data/lib/dommy/internal/css/computed_style_builder.rb +30 -3
  74. data/lib/dommy/internal/css/custom_properties.rb +63 -80
  75. data/lib/dommy/internal/css/parser.rb +53 -63
  76. data/lib/dommy/internal/css/renderability.rb +6 -0
  77. data/lib/dommy/internal/css/rule_index.rb +170 -108
  78. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  79. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  80. data/lib/dommy/internal/css/style_cache.rb +9 -6
  81. data/lib/dommy/internal/css/ua_stylesheet.rb +37 -2
  82. data/lib/dommy/internal/css_source.rb +223 -0
  83. data/lib/dommy/internal/css_syntax.rb +153 -0
  84. data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
  85. data/lib/dommy/internal/directionality.rb +2 -2
  86. data/lib/dommy/internal/document_generations.rb +91 -18
  87. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  88. data/lib/dommy/internal/dom_matching.rb +7 -7
  89. data/lib/dommy/internal/element_aria.rb +65 -23
  90. data/lib/dommy/internal/element_css_inline_style.rb +26 -0
  91. data/lib/dommy/internal/element_editing.rb +41 -0
  92. data/lib/dommy/internal/element_nonce.rb +28 -0
  93. data/lib/dommy/internal/element_popover.rb +310 -0
  94. data/lib/dommy/internal/element_shadow.rb +107 -33
  95. data/lib/dommy/internal/element_state.rb +131 -26
  96. data/lib/dommy/internal/element_top_layer.rb +21 -51
  97. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  98. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  99. data/lib/dommy/internal/event_handlers.rb +149 -0
  100. data/lib/dommy/internal/exception_report.rb +1 -1
  101. data/lib/dommy/internal/focusability.rb +267 -0
  102. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  103. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +86 -0
  104. data/lib/dommy/internal/html_serialization.rb +292 -0
  105. data/lib/dommy/internal/idl_reflection.rb +202 -0
  106. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  107. data/lib/dommy/internal/infra.rb +36 -0
  108. data/lib/dommy/internal/input_type.rb +90 -20
  109. data/lib/dommy/internal/insertion_point.rb +27 -3
  110. data/lib/dommy/internal/js_number.rb +60 -0
  111. data/lib/dommy/internal/light_dismiss.rb +178 -0
  112. data/lib/dommy/internal/literal_lookup.rb +103 -0
  113. data/lib/dommy/internal/mime_type.rb +8 -0
  114. data/lib/dommy/internal/mutation_coordinator.rb +94 -60
  115. data/lib/dommy/internal/node_adopter.rb +165 -97
  116. data/lib/dommy/internal/node_factory.rb +111 -49
  117. data/lib/dommy/internal/node_identity.rb +1 -1
  118. data/lib/dommy/internal/node_traversal.rb +30 -13
  119. data/lib/dommy/internal/node_wrapper_cache.rb +34 -148
  120. data/lib/dommy/internal/observable_callback.rb +2 -5
  121. data/lib/dommy/internal/observer_options.rb +24 -14
  122. data/lib/dommy/internal/origin.rb +86 -0
  123. data/lib/dommy/internal/parent_node.rb +12 -10
  124. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  125. data/lib/dommy/internal/popover_stack.rb +120 -0
  126. data/lib/dommy/internal/post_insertion_steps.rb +146 -77
  127. data/lib/dommy/internal/reflected_attributes.rb +173 -43
  128. data/lib/dommy/internal/rendered_text/collector.rb +12 -9
  129. data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
  130. data/lib/dommy/internal/selector_arguments.rb +2 -16
  131. data/lib/dommy/internal/selector_index.rb +19 -10
  132. data/lib/dommy/internal/selector_match.rb +190 -0
  133. data/lib/dommy/internal/selector_matcher.rb +91 -256
  134. data/lib/dommy/internal/selector_parser.rb +176 -100
  135. data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
  136. data/lib/dommy/internal/shadow_root_registry.rb +14 -6
  137. data/lib/dommy/internal/sibling_positions.rb +59 -0
  138. data/lib/dommy/internal/svg_uri_reference.rb +33 -0
  139. data/lib/dommy/internal/template_content_registry.rb +11 -5
  140. data/lib/dommy/internal/text_selection.rb +185 -32
  141. data/lib/dommy/internal/toggle_events.rb +6 -4
  142. data/lib/dommy/internal/unsafe_html.rb +107 -0
  143. data/lib/dommy/internal/url_parser.rb +84 -8
  144. data/lib/dommy/internal/user_activation.rb +192 -0
  145. data/lib/dommy/internal/web_idl.rb +160 -16
  146. data/lib/dommy/internal/window_constructors.rb +34 -14
  147. data/lib/dommy/internal/xml_serialization.rb +9 -45
  148. data/lib/dommy/js/constructor_resolver.rb +18 -0
  149. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  150. data/lib/dommy/js/dom_interfaces.rb +51 -15
  151. data/lib/dommy/js/host_bridge.rb +189 -40
  152. data/lib/dommy/js/host_callbacks.rb +10 -0
  153. data/lib/dommy/js/host_runtime.js +1921 -416
  154. data/lib/dommy/js/marshaller.rb +13 -4
  155. data/lib/dommy/js/module_loader.rb +46 -5
  156. data/lib/dommy/js/module_preload.rb +194 -0
  157. data/lib/dommy/js/runtime.rb +9 -0
  158. data/lib/dommy/js/script_boot.rb +200 -114
  159. data/lib/dommy/js/serialized_record.rb +32 -0
  160. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  161. data/lib/dommy/js/webidl_members.js +196 -0
  162. data/lib/dommy/js/webidl_signatures.js +2055 -0
  163. data/lib/dommy/js/webidl_tables.js +72 -101
  164. data/lib/dommy/js/window_wiring.rb +9 -5
  165. data/lib/dommy/location.rb +129 -76
  166. data/lib/dommy/mathml_elements.rb +13 -0
  167. data/lib/dommy/media_query_list.rb +4 -13
  168. data/lib/dommy/message_channel.rb +137 -64
  169. data/lib/dommy/mutation_observer.rb +3 -7
  170. data/lib/dommy/navigation.rb +61 -11
  171. data/lib/dommy/navigator.rb +132 -10
  172. data/lib/dommy/node.rb +81 -44
  173. data/lib/dommy/notification.rb +10 -6
  174. data/lib/dommy/parser.rb +5 -16
  175. data/lib/dommy/promise.rb +20 -26
  176. data/lib/dommy/range.rb +20 -47
  177. data/lib/dommy/resources.rb +38 -3
  178. data/lib/dommy/scheduler.rb +176 -48
  179. data/lib/dommy/shadow_root.rb +125 -25
  180. data/lib/dommy/storage.rb +135 -25
  181. data/lib/dommy/streams.rb +2 -6
  182. data/lib/dommy/structured_data.rb +45 -0
  183. data/lib/dommy/style_declaration.rb +38 -6
  184. data/lib/dommy/svg_elements.rb +25 -11
  185. data/lib/dommy/text_codec.rb +2 -14
  186. data/lib/dommy/tree_walker.rb +12 -26
  187. data/lib/dommy/url.rb +12 -9
  188. data/lib/dommy/url_pattern.rb +1 -9
  189. data/lib/dommy/validity_state.rb +109 -107
  190. data/lib/dommy/version.rb +1 -1
  191. data/lib/dommy/web_socket.rb +10 -33
  192. data/lib/dommy/window.rb +817 -105
  193. data/lib/dommy/worker.rb +4 -28
  194. data/lib/dommy/xml_http_request.rb +32 -45
  195. data/lib/dommy.rb +43 -10
  196. metadata +52 -5
  197. data/lib/dommy/backend/makiri_adapter.rb +0 -408
  198. data/lib/dommy/internal/cookie_jar.rb +0 -29
@@ -13,8 +13,8 @@ module Dommy
13
13
  # mid-week, so steps measure from 1970-W01) lived three methods away from
14
14
  # the code that converts a week string. Here a type is one class.
15
15
  #
16
- # Only the numeric surface lives here. Value sanitization stays on the
17
- # element, where it reads other attributes (`multiple` for email).
16
+ # Only the numeric and Date surface lives here. Value sanitization stays on
17
+ # the element, where it reads other attributes (`multiple` for email).
18
18
  class InputType
19
19
  # The type `name` stands for. An unknown or non-numeric type answers the
20
20
  # one that has no numbers, so callers never branch on nil. REGISTRY and
@@ -25,6 +25,10 @@ module Dommy
25
25
  # Whether valueAsNumber / stepUp / stepDown apply at all.
26
26
  def numeric? = true
27
27
 
28
+ # Whether valueAsDate applies: date, month, week and time only. A
29
+ # datetime-local value names no instant (it has no time zone).
30
+ def date? = false
31
+
28
32
  # How many of the number's units one step is worth.
29
33
  def scale_factor = 1
30
34
 
@@ -51,6 +55,14 @@ module Dommy
51
55
  # the one that knows which type to name in the error.
52
56
  def from_number(_number) = raise(NotImplementedError)
53
57
 
58
+ # The value string as a Date's time value (ms since the epoch), or NaN
59
+ # when it is not one. The number valueAsNumber reads, for every date type
60
+ # but month, which counts months.
61
+ def to_date_value(text) = to_number(text)
62
+
63
+ # A Date's time value as the value string.
64
+ def from_date_value(time_value) = from_number(time_value)
65
+
54
66
  # A `min`/`max` attribute as a number, or nil when it is not one. The
55
67
  # non-numeric type parses nothing, so this is nil for it too.
56
68
  def boundary(text) = nan_to_nil(to_number(text))
@@ -74,9 +86,21 @@ module Dommy
74
86
 
75
87
  VALID_FLOAT = /\A-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?\z/
76
88
 
89
+ # Float(string), raising ArgumentError for what is no number. A number
90
+ # past a double's range comes out as ±Infinity, which every caller
91
+ # already reads as no valid number; Ruby warns about it in verbose mode,
92
+ # so the warning is held off for the conversion.
93
+ def self.float(string)
94
+ verbose = $VERBOSE
95
+ $VERBOSE = nil
96
+ Float(string)
97
+ ensure
98
+ $VERBOSE = verbose
99
+ end
100
+
77
101
  def to_number(text)
78
102
  string = text.to_s
79
- VALID_FLOAT.match?(string) ? (Float(string) rescue ::Float::NAN) : ::Float::NAN
103
+ VALID_FLOAT.match?(string) ? NumberInputType.float(string) : ::Float::NAN
80
104
  end
81
105
 
82
106
  def from_number(number)
@@ -85,7 +109,7 @@ module Dommy
85
109
  number == number.to_i ? number.to_i.to_s : number.to_s
86
110
  end
87
111
 
88
- def boundary(text) = (Float(text) rescue nil)
112
+ def boundary(text) = (NumberInputType.float(text) rescue nil)
89
113
  end
90
114
 
91
115
  # A range always reads as a number: an unparseable value is the midpoint of
@@ -94,24 +118,46 @@ module Dommy
94
118
 
95
119
  def value_of(text, element)
96
120
  number = to_number(text)
97
- low = (Float(element.get_attribute("min").to_s) rescue 0.0)
98
- high = (Float(element.get_attribute("max").to_s) rescue 100.0)
99
- number = nil if number.nan?
100
- number ||= high < low ? low : low + (high - low) / 2.0
101
- number.clamp(low, high)
121
+ low = finite_attribute(element, "min") || 0.0
122
+ high = finite_attribute(element, "max") || 100.0
123
+ number = high < low ? low : low / 2.0 + high / 2.0 unless number.finite?
124
+ number = [number, low].max
125
+ number = [number, high].min if high >= low
126
+ return number if element.__internal_attribute_value__("step") == "any"
127
+
128
+ step = finite_attribute(element, "step")
129
+ step = default_step unless step&.positive?
130
+ base = finite_attribute(element, "min") || finite_attribute(element, "value") || step_base
131
+ # Round in decimal arithmetic so a valid 0.6 at step 0.1 does not
132
+ # become 0.6000000000000001, and tiny steps cannot overflow the ratio.
133
+ number = number.to_s.to_r
134
+ base = base.to_s.to_r
135
+ step = step.to_s.to_r
136
+ offset = (number - base) / step
137
+ candidates = [offset.floor, offset.ceil].map { |n| base + n * step }
138
+ .select { |n| n >= low && (high < low || n <= high) }
139
+ (candidates.min_by { |n| [(n - number).abs, -n] } || number).to_f
140
+ end
141
+
142
+ private
143
+
144
+ def finite_attribute(element, name)
145
+ number = to_number(element.__internal_attribute_value__(name).to_s)
146
+ number if number.finite?
102
147
  end
103
148
  end
104
149
 
105
150
  class DateInputType < InputType
106
151
 
107
152
  def scale_factor = 86_400_000
153
+ def date? = true
108
154
 
109
155
  def to_number(text)
110
- match = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(text.to_s.strip)
156
+ match = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(text.to_s)
111
157
  return ::Float::NAN unless match
112
158
 
113
159
  year, month, day = match[1].to_i, match[2].to_i, match[3].to_i
114
- return ::Float::NAN if year < 1 || !::Date.valid_date?(year, month, day)
160
+ return ::Float::NAN if year < 1 || !::Date.valid_date?(year, month, day, ::Date::GREGORIAN)
115
161
 
116
162
  ::Time.utc(year, month, day).to_i * 1000.0
117
163
  end
@@ -127,13 +173,15 @@ module Dommy
127
173
 
128
174
  end
129
175
 
176
+ # A time is the time of day; as a Date it falls on 1970-01-01 UTC.
130
177
  class TimeInputType < InputType
131
178
 
132
179
  def scale_factor = 1000
180
+ def date? = true
133
181
  def default_step = 60.0
134
182
 
135
183
  def to_number(text)
136
- match = /\A(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?\z/.match(text.to_s.strip)
184
+ match = /\A(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?\z/.match(text.to_s)
137
185
  return ::Float::NAN unless match
138
186
 
139
187
  hour, minute, second = match[1].to_i, match[2].to_i, match[3].to_i
@@ -171,12 +219,12 @@ module Dommy
171
219
  # The date/time separator may be "T" or a space (the "parse a local date
172
220
  # and time string" algorithm accepts both).
173
221
  match = /\A(\d{4,})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?\z/
174
- .match(text.to_s.strip)
222
+ .match(text.to_s)
175
223
  return ::Float::NAN unless match
176
224
 
177
225
  year, month, day = match[1].to_i, match[2].to_i, match[3].to_i
178
226
  hour, minute, second = match[4].to_i, match[5].to_i, match[6].to_i
179
- return ::Float::NAN if year < 1 || !::Date.valid_date?(year, month, day)
227
+ return ::Float::NAN if year < 1 || !::Date.valid_date?(year, month, day, ::Date::GREGORIAN)
180
228
  return ::Float::NAN if hour > 23 || minute > 59 || second > 59
181
229
 
182
230
  fraction = match[7] ? match[7].ljust(3, "0").to_i : 0
@@ -184,10 +232,10 @@ module Dommy
184
232
  end
185
233
 
186
234
  def from_number(number)
187
- return "" if number.nan?
235
+ return "" unless number.finite? && number.abs <= Bridge::Date::MAX_TIME_VALUE
188
236
 
189
237
  time = utc_time_from_ms(number)
190
- return "" if time.year < 1 || time.year > 9999
238
+ return "" if time.year < 1
191
239
 
192
240
  base = format("%04d-%02d-%02dT%02d:%02d", time.year, time.month, time.day, time.hour, time.min)
193
241
  fraction = (number % 1000).to_i
@@ -196,7 +244,7 @@ module Dommy
196
244
  elsif fraction.zero?
197
245
  base + format(":%02d", time.sec)
198
246
  else
199
- base + format(":%02d.%03d", time.sec, fraction)
247
+ base + format(":%02d.%03d", time.sec, fraction).sub(/0+\z/, "")
200
248
  end
201
249
  rescue ::RangeError, ::ArgumentError, ::FloatDomainError
202
250
  ""
@@ -208,8 +256,10 @@ module Dommy
208
256
  # its scale is 1.
209
257
  class MonthInputType < InputType
210
258
 
259
+ def date? = true
260
+
211
261
  def to_number(text)
212
- match = /\A(\d{4,})-(\d{2})\z/.match(text.to_s.strip)
262
+ match = /\A(\d{4,})-(\d{2})\z/.match(text.to_s)
213
263
  return ::Float::NAN unless match
214
264
 
215
265
  year, month = match[1].to_i, match[2].to_i
@@ -226,11 +276,31 @@ module Dommy
226
276
  format("%04d-%02d", year, (months % 12) + 1)
227
277
  end
228
278
 
279
+ # As a Date, a month is the UTC midnight that starts it.
280
+ def to_date_value(text)
281
+ months = to_number(text)
282
+ return months if months.nan?
283
+
284
+ months = months.to_i
285
+ ::Time.utc(1970 + months.fdiv(12).floor, (months % 12) + 1).to_i * 1000.0
286
+ end
287
+
288
+ def from_date_value(time_value)
289
+ return "" unless time_value.finite?
290
+
291
+ time = utc_time_from_ms(time_value)
292
+ format("%04d-%02d", time.year, time.month)
293
+ rescue ::RangeError, ::ArgumentError, ::FloatDomainError
294
+ ""
295
+ end
296
+
229
297
  end
230
298
 
299
+ # As a Date, a week is the UTC midnight of its Monday.
231
300
  class WeekInputType < InputType
232
301
 
233
302
  def scale_factor = 604_800_000
303
+ def date? = true
234
304
 
235
305
  # The epoch falls mid-week, so a week control aligns to the Monday of
236
306
  # 1970-W01; measuring from 0 would report every whole week as a step
@@ -238,14 +308,14 @@ module Dommy
238
308
  def step_base = to_number("1970-W01")
239
309
 
240
310
  def to_number(text)
241
- match = /\A(\d{4,})-W(\d{2})\z/.match(text.to_s.strip)
311
+ match = /\A(\d{4,})-W(\d{2})\z/.match(text.to_s)
242
312
  return ::Float::NAN unless match
243
313
 
244
314
  year, week = match[1].to_i, match[2].to_i
245
315
  return ::Float::NAN if year < 1 || week < 1
246
316
 
247
317
  # Date.commercial raises for a week beyond the ISO year's 52/53 weeks.
248
- date = ::Date.commercial(year, week, 1)
318
+ date = ::Date.commercial(year, week, 1, ::Date::GREGORIAN)
249
319
  ::Time.utc(date.year, date.month, date.day).to_i * 1000.0
250
320
  rescue ::ArgumentError
251
321
  ::Float::NAN
@@ -13,6 +13,30 @@ module Dommy
13
13
  module InsertionPoint
14
14
  module_function
15
15
 
16
+ # WHATWG "convert nodes into a node" in `document`, for the variadic
17
+ # methods (ChildNode's, ParentNode's and the Document's own): two or
18
+ # more arguments are appended, in order, to a new DocumentFragment —
19
+ # strings as new Text nodes — and the fragment stands for them. That
20
+ # append moves each node out of wherever it was and checks it as any
21
+ # append does, so it can fail half way (a doctype or a Document among
22
+ # the arguments): the nodes appended by then stay in the fragment, out of
23
+ # the tree, as the spec has it. One argument is left as it is: a string
24
+ # becomes a Text node where it is inserted, and a node moves only then.
25
+ #
26
+ # Spec: https://dom.spec.whatwg.org/#converting-nodes-into-a-node
27
+ def convert_nodes_into_a_node(document, args)
28
+ return args if args.size < 2
29
+
30
+ fragment = document.create_document_fragment
31
+ args.each do |arg|
32
+ case arg
33
+ when Dommy::Node then fragment.append_child(arg)
34
+ when String then fragment.append_child(document.create_text_node(arg))
35
+ end
36
+ end
37
+ [fragment]
38
+ end
39
+
16
40
  # Insert step 1's count: how many nodes `args` will contribute once
17
41
  # converted. A DocumentFragment expands to its children, a String becomes
18
42
  # one Text node, and anything without a backing node contributes nothing.
@@ -21,9 +45,9 @@ module Dommy
21
45
  def count(args)
22
46
  args.sum do |arg|
23
47
  case arg
24
- when Fragment then arg.__dommy_backend_node__.children.to_a.size
48
+ when Fragment then arg.__dommy_backend_node__.child_count
25
49
  when String then 1
26
- else arg.respond_to?(:__dommy_backend_node__) ? 1 : 0
50
+ else arg.is_a?(Node) && arg.__dommy_backend_node__ ? 1 : 0
27
51
  end
28
52
  end
29
53
  end
@@ -32,7 +56,7 @@ module Dommy
32
56
  # or the parent's last child when appending. Measured before anything
33
57
  # moves. Returns the backend node; the caller wraps it.
34
58
  def previous_sibling(parent, ref)
35
- ref ? ref.previous_sibling : parent.children.to_a.last
59
+ ref ? ref.previous_sibling : parent.last_child
36
60
  end
37
61
 
38
62
  # Advance `ref` past any node that is itself being inserted: it is about
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # ECMAScript's Number::toString(x) with radix 10 — what JS's `String(x)`
6
+ # gives, and what HTML calls "the best representation of the number as a
7
+ # floating-point number" (the string a double reflection, a meter or a
8
+ # number input writes). Ruby's Float#to_s picks the same shortest
9
+ # round-trip digits but lays them out differently ("1.0e-10", "5.0",
10
+ # "1.0e+16"), so this re-lays them out by the ECMAScript rules:
11
+ #
12
+ # to_string(5.0) # => "5"
13
+ # to_string(-0.0) # => "0"
14
+ # to_string(1e21) # => "1e+21"
15
+ # to_string(1e-7) # => "1e-7"
16
+ # to_string(1.5e-6) # => "0.0000015"
17
+ # to_string(1e25) # => "1e+25"
18
+ #
19
+ # Spec: https://tc39.es/ecma262/#sec-numeric-types-number-tostring
20
+ module JsNumber
21
+ module_function
22
+
23
+ def to_string(number)
24
+ x = Float(number)
25
+ return "NaN" if x.nan?
26
+ return x.positive? ? "Infinity" : "-Infinity" if x.infinite?
27
+ return "0" if x.zero? # +0 and -0 alike
28
+ return "-#{to_string(-x)}" if x.negative?
29
+
30
+ digits, n = shortest_digits(x)
31
+ k = digits.length
32
+ if k <= n && n <= 21
33
+ digits + ("0" * (n - k))
34
+ elsif n.positive? && n <= 21
35
+ "#{digits[0, n]}.#{digits[n..]}"
36
+ elsif n > -6 && n <= 0
37
+ "0.#{"0" * -n}#{digits}"
38
+ else
39
+ exponent = n - 1
40
+ sign = exponent.negative? ? "-" : "+"
41
+ mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
42
+ "#{mantissa}e#{sign}#{exponent.abs}"
43
+ end
44
+ end
45
+
46
+ # [s, n] for a finite positive x: the shortest digit string s (no leading
47
+ # or trailing zeros) with x = 0.s × 10^n — ECMAScript's k digits and n.
48
+ # Float#to_s already chose the digits; this only reads them back out.
49
+ def shortest_digits(x)
50
+ mantissa, exponent = x.to_s.split("e")
51
+ int_part, frac_part = mantissa.split(".")
52
+ all = int_part + frac_part.to_s
53
+ point = int_part.length + exponent.to_i
54
+ stripped = all.sub(/\A0+/, "")
55
+ point -= all.length - stripped.length
56
+ [stripped.sub(/0+\z/, ""), point]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # HTML's light dismiss: "run light dismiss activities" for a trusted
6
+ # pointerdown or pointerup — "light dismiss open popovers" (§6.12) and
7
+ # "light dismiss open dialogs" (the dialog element). Pointer Events calls
8
+ # it as the user presses and releases the pointer, before the pointer
9
+ # event is dispatched; Dommy's Interaction layer does the same for its
10
+ # clicks.
11
+ #
12
+ # A press records the topmost popover (and the nearest open dialog) the
13
+ # pointer went down in; the release, when it lands in the same one,
14
+ # closes the auto and hint popovers above it, and requests to close the
15
+ # topmost `closedby=any` dialog when that is not the dialog clicked.
16
+ module LightDismiss
17
+ module_function
18
+
19
+ # Run light dismiss activities for `event` (a trusted PointerEvent of
20
+ # type pointerdown or pointerup) aimed at `target`. `backdrop` says the
21
+ # pointer hit a modal dialog's ::backdrop rather than its box — the
22
+ # coordinates were outside the dialog.
23
+ def run(event, target, backdrop: false)
24
+ return unless event.__js_get__("isTrusted") == true
25
+ return unless target.is_a?(Node)
26
+
27
+ light_dismiss_open_popovers(event.type, target)
28
+ light_dismiss_open_dialogs(event.type, target, backdrop)
29
+ nil
30
+ end
31
+
32
+ # HTML "light dismiss open popovers".
33
+ def light_dismiss_open_popovers(type, target)
34
+ document = target.owner_document
35
+ stack = document.__internal_popover_stack__
36
+ return if stack.topmost_auto_or_hint.nil?
37
+
38
+ case type
39
+ when "pointerdown"
40
+ document.__internal_popover_pointerdown_target__ = topmost_clicked_popover(target)
41
+ when "pointerup"
42
+ ancestor = topmost_clicked_popover(target)
43
+ same_target = ancestor.equal?(document.__internal_popover_pointerdown_target__)
44
+ document.__internal_popover_pointerdown_target__ = nil
45
+ return unless same_target
46
+
47
+ stack.hide_popovers_until(ancestor, false, true)
48
+ end
49
+ end
50
+
51
+ # HTML "light dismiss open dialogs".
52
+ def light_dismiss_open_dialogs(type, target, backdrop)
53
+ document = target.owner_document
54
+ open_dialogs = document.__internal_open_dialogs__
55
+ return if open_dialogs.empty?
56
+
57
+ ancestor = nearest_clicked_dialog(target, backdrop)
58
+ case type
59
+ when "pointerdown"
60
+ document.__internal_dialog_pointerdown_target__ = ancestor
61
+ when "pointerup"
62
+ same_target = ancestor.equal?(document.__internal_dialog_pointerdown_target__)
63
+ document.__internal_dialog_pointerdown_target__ = nil
64
+ return unless same_target
65
+
66
+ topmost = open_dialogs.last
67
+ return if ancestor.equal?(topmost)
68
+ return unless topmost.closed_by == "any"
69
+
70
+ topmost.__internal_request_close_watcher__(false)
71
+ end
72
+ end
73
+
74
+ # HTML "nearest clicked dialog": nil for a hit on an open modal
75
+ # dialog's backdrop, else the nearest open dialog among the target's
76
+ # flat-tree inclusive ancestors.
77
+ def nearest_clicked_dialog(target, backdrop)
78
+ return nil if backdrop && open_dialog?(target) && target.__internal_modal__?
79
+
80
+ node = target
81
+ until node.nil?
82
+ return node if open_dialog?(node)
83
+
84
+ node = flat_tree_parent(node)
85
+ end
86
+ nil
87
+ end
88
+
89
+ # HTML "topmost clicked popover".
90
+ def topmost_clicked_popover(node)
91
+ clicked = nearest_inclusive_open_popover(node)
92
+ target = nearest_inclusive_target_popover(node)
93
+ stack_position(clicked) > stack_position(target) ? clicked : target
94
+ end
95
+
96
+ # HTML "nearest inclusive open popover".
97
+ def nearest_inclusive_open_popover(node)
98
+ current = node
99
+ until current.nil?
100
+ if current.respond_to?(:__internal_popover_opened_mode__) && current.__internal_popover_opened_mode__ &&
101
+ current.__internal_popover_showing__?
102
+ return current
103
+ end
104
+
105
+ current = flat_tree_parent(current)
106
+ end
107
+ nil
108
+ end
109
+
110
+ # HTML "nearest inclusive target popover".
111
+ def nearest_inclusive_target_popover(node)
112
+ current = node
113
+ until current.nil?
114
+ popover = target_popover(current)
115
+ if popover && %w[auto hint].include?(popover.popover) && popover.__internal_popover_showing__?
116
+ return popover
117
+ end
118
+
119
+ current = flat_tree_parent(current)
120
+ end
121
+ nil
122
+ end
123
+
124
+ # HTML "get the target popover": what a button's commandfor names, when
125
+ # its command is a popover one, else its popover target element.
126
+ def target_popover(node)
127
+ return nil unless node.is_a?(HTMLElement)
128
+
129
+ popover_target = node.respond_to?(:__internal_popover_target_element__) ? node.__internal_popover_target_element__ : nil
130
+ return popover_target unless node.is_a?(HTMLButtonElement)
131
+ return nil if node.__internal_actually_disabled__
132
+
133
+ target = node.command_for_element
134
+ return popover_target if target.nil?
135
+
136
+ if node.form
137
+ return nil if node.__internal_submit_button_state__?
138
+ return nil if %w[reset auto].include?(node.type_state)
139
+ end
140
+ return nil unless HTMLButtonElement::POPOVER_COMMANDS.include?(node.command)
141
+ return nil unless target.is_a?(HTMLElement) && !target.popover.nil?
142
+
143
+ target
144
+ end
145
+
146
+ # HTML "get the popover stack position".
147
+ def stack_position(popover)
148
+ return 0 if popover.nil?
149
+
150
+ stack = popover.owner_document.__internal_popover_stack__
151
+ hint = stack.list("hint")
152
+ auto = stack.list("auto")
153
+ if (index = hint.index { |p| p.equal?(popover) })
154
+ return index + auto.size + 1
155
+ end
156
+ if (index = auto.index { |p| p.equal?(popover) })
157
+ return index + 1
158
+ end
159
+
160
+ 0
161
+ end
162
+
163
+ def open_dialog?(node)
164
+ node.is_a?(HTMLDialogElement) && node.__internal_has_attribute__?("open")
165
+ end
166
+
167
+ # A node's parent in the flat tree, up to the document element.
168
+ def flat_tree_parent(node)
169
+ parent = Focusability.flat_tree_parent(node)
170
+ parent.is_a?(Element) ? parent : nil
171
+ end
172
+
173
+ private_class_method :light_dismiss_open_popovers, :light_dismiss_open_dialogs, :nearest_clicked_dialog,
174
+ :topmost_clicked_popover, :nearest_inclusive_open_popover, :nearest_inclusive_target_popover,
175
+ :target_popover, :stack_position, :open_dialog?, :flat_tree_parent
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "infra"
4
+
5
+ module Dommy
6
+ module Internal
7
+ # getElementById, getElementsByClassName and getElementsByName: each
8
+ # compares an attribute with a string, and none of them takes a CSS
9
+ # selector. A value that would be special in one — a digit first, `.`, `:`,
10
+ # a quote, a backslash, a newline, NUL — must still be found and never
11
+ # raise, so these read the backend tree rather than compose a selector
12
+ # string from their argument (getElementById uses the backend's id
13
+ # selector as an index, and checks what it answers).
14
+ module LiteralLookup
15
+ module_function
16
+
17
+ # The first backend element under `bnode` whose `id` is `id`, in tree
18
+ # order. An id with selector-special characters (e.g. React's `useId`
19
+ # values like `:rjm:`) is escaped into a valid id-selector ident for the
20
+ # backend's engine (#selector_ident). In a quirks-mode document that
21
+ # engine matches an id selector ASCII case-insensitively, as CSS asks,
22
+ # while getElementById still compares case-sensitively: its answer is
23
+ # kept only when the id is exactly `id`, and otherwise the exact one is
24
+ # looked for among the rest it matches. No escape spells U+0000 (it
25
+ # reads as U+FFFD), so an id holding it is found by comparing the
26
+ # attribute itself. An element's id, like its classes and its name, is
27
+ # the attribute of that name in no namespace (#attribute).
28
+ def element_by_id(bnode, id)
29
+ return bnode.css("[id]").find { |n| attribute(n, "id") == id } if id.include?("\u0000")
30
+
31
+ selector = "##{selector_ident(id)}"
32
+ first = bnode.at_css(selector)
33
+ return first if first.nil? || attribute(first, "id") == id
34
+
35
+ bnode.css(selector).find { |n| attribute(n, "id") == id }
36
+ end
37
+
38
+ # The class tokens getElementsByClassName looks for in `names`.
39
+ def class_tokens(names) = Infra.split_on_ascii_whitespace(names)
40
+
41
+ # The backend elements under `root`, a node of `document`, whose class
42
+ # list contains every one of `tokens`, compared directly: a token may
43
+ # hold anything but ASCII whitespace (`1`, `a.b`, `a:b`, `[x]`, a quote,
44
+ # NUL). In a quirks-mode document they compare ASCII case-insensitively.
45
+ # The backend's own class selector narrows the candidates first (see
46
+ # #class_candidates).
47
+ def elements_with_classes(document, root, tokens)
48
+ quirks = document.quirks_mode?
49
+ wanted = quirks ? tokens.map { |t| t.downcase(:ascii) } : tokens
50
+ class_candidates(document, root, tokens).select do |n|
51
+ classes = Infra.split_on_ascii_whitespace(attribute(n, "class"))
52
+ classes = classes.map { |c| c.downcase(:ascii) } if quirks
53
+ wanted.all? { |t| classes.include?(t) }
54
+ end
55
+ end
56
+
57
+ # A superset of the elements with every one of `tokens`: what the
58
+ # backend's class selector, the tokens escaped by #selector_ident,
59
+ # matches — natively, where the comparison above runs in Ruby. That
60
+ # selector compares exactly where the backend parsed a document in
61
+ # no-quirks mode and ASCII case-insensitively where it parsed one in
62
+ # quirks mode; Dommy takes the document's mode from that parse, so the
63
+ # selector is never stricter than the comparison above — but a superset
64
+ # must not rest on that, so a quirks-mode `document` over a backend that
65
+ # does not fold scans instead. It cannot spell U+0000 (an escape of it
66
+ # reads as U+FFFD); then too every element with a class is a candidate.
67
+ def class_candidates(document, root, tokens)
68
+ backend_folds = !document.quirks_mode? || Backend.quirks_mode?(document.backend_doc)
69
+ return root.css("[class]") if !backend_folds || tokens.any? { |t| t.include?("\u0000") }
70
+
71
+ root.css(tokens.map { |t| ".#{selector_ident(t)}" }.join)
72
+ end
73
+
74
+ # `value` as an ident a selector can carry whatever it holds: every code
75
+ # point but an ASCII letter, an underscore, and (after the first) a
76
+ # digit or U+002D written as a hex escape. CSS.escape is not enough —
77
+ # it leaves a non-ASCII code point as it is, and css-syntax-3 counts
78
+ # only some of them as ident code points (§4.2), so a class or an id of
79
+ # U+00A0 or U+3000 would make an invalid selector.
80
+ def selector_ident(value)
81
+ value.each_char.with_index.map do |c, i|
82
+ plain = i.zero? ? c.match?(/[A-Za-z_]/) : c.match?(/[A-Za-z0-9_-]/)
83
+ plain ? c : "\\#{c.ord.to_s(16)} "
84
+ end.join
85
+ end
86
+
87
+ # The HTML elements under `bnode` whose `name` is `name`. Only elements
88
+ # in the HTML namespace count: an SVG or MathML element with a `name`
89
+ # attribute is not one getElementsByName finds.
90
+ #
91
+ # Spec: https://html.spec.whatwg.org/multipage/dom.html#dom-document-getelementsbyname
92
+ def elements_named(bnode, name)
93
+ bnode.css("[name]").select { |n| Backend.namespace_uri(n) == Namespaces::HTML && attribute(n, "name") == name }
94
+ end
95
+
96
+ # The value of `node`'s attribute `name` in no namespace: an `id`,
97
+ # `class` or `name` set with setAttributeNS("urn:x", …) is none of them.
98
+ def attribute(node, name) = Backend.no_namespace_attribute_value(node, name)
99
+
100
+ private_class_method :class_candidates, :selector_ident, :attribute
101
+ end
102
+ end
103
+ end
@@ -19,6 +19,14 @@ module Dommy
19
19
 
20
20
  module_function
21
21
 
22
+ # Whether the essence names an XML MIME type: text/xml, application/xml,
23
+ # or any subtype ending in "+xml" (image/svg+xml, application/xhtml+xml).
24
+ # https://mimesniff.spec.whatwg.org/#xml-mime-type
25
+ def xml?(essence)
26
+ essence = essence.to_s
27
+ essence == "text/xml" || essence == "application/xml" || essence.end_with?("+xml")
28
+ end
29
+
22
30
  # The value of `name` among the MIME type's parameters, or nil. The name
23
31
  # is ASCII case-insensitive; a quoted value comes back unquoted.
24
32
  def parameter(mime_type, name)