stimeo-ui 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 (279) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +120 -0
  3. package/dist/cable/index.js +123 -29
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/accordion_controller.d.ts +30 -3
  6. package/dist/controllers/accordion_controller.js +98 -9
  7. package/dist/controllers/accordion_controller.js.map +1 -1
  8. package/dist/controllers/alert_dialog_controller.d.ts +8 -7
  9. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  10. package/dist/controllers/announcer_controller.d.ts +3 -2
  11. package/dist/controllers/announcer_controller.js +96 -62
  12. package/dist/controllers/announcer_controller.js.map +1 -1
  13. package/dist/controllers/auto_submit_controller.js +83 -7
  14. package/dist/controllers/auto_submit_controller.js.map +1 -1
  15. package/dist/controllers/avatar_controller.js +1 -1
  16. package/dist/controllers/avatar_controller.js.map +1 -1
  17. package/dist/controllers/breadcrumb_controller.d.ts +10 -7
  18. package/dist/controllers/breadcrumb_controller.js +38 -11
  19. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  20. package/dist/controllers/bulk_select_controller.d.ts +4 -4
  21. package/dist/controllers/bulk_select_controller.js +7 -6
  22. package/dist/controllers/bulk_select_controller.js.map +1 -1
  23. package/dist/controllers/calendar_controller.d.ts +101 -23
  24. package/dist/controllers/calendar_controller.js +340 -123
  25. package/dist/controllers/calendar_controller.js.map +1 -1
  26. package/dist/controllers/carousel_controller.d.ts +67 -22
  27. package/dist/controllers/carousel_controller.js +263 -38
  28. package/dist/controllers/carousel_controller.js.map +1 -1
  29. package/dist/controllers/character_counter_controller.d.ts +1 -1
  30. package/dist/controllers/character_counter_controller.js +40 -2
  31. package/dist/controllers/character_counter_controller.js.map +1 -1
  32. package/dist/controllers/checkbox_controller.js +81 -12
  33. package/dist/controllers/checkbox_controller.js.map +1 -1
  34. package/dist/controllers/clipboard_controller.d.ts +28 -2
  35. package/dist/controllers/clipboard_controller.js +63 -5
  36. package/dist/controllers/clipboard_controller.js.map +1 -1
  37. package/dist/controllers/collapsible_controller.d.ts +25 -1
  38. package/dist/controllers/collapsible_controller.js +99 -14
  39. package/dist/controllers/collapsible_controller.js.map +1 -1
  40. package/dist/controllers/color_picker_controller.d.ts +29 -8
  41. package/dist/controllers/color_picker_controller.js +80 -34
  42. package/dist/controllers/color_picker_controller.js.map +1 -1
  43. package/dist/controllers/combobox_controller.d.ts +10 -1
  44. package/dist/controllers/combobox_controller.js +106 -16
  45. package/dist/controllers/combobox_controller.js.map +1 -1
  46. package/dist/controllers/command_palette_controller.d.ts +3 -3
  47. package/dist/controllers/command_palette_controller.js +35 -3
  48. package/dist/controllers/command_palette_controller.js.map +1 -1
  49. package/dist/controllers/conditional_fields_controller.js +85 -17
  50. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  51. package/dist/controllers/confirm_controller.js +3 -0
  52. package/dist/controllers/confirm_controller.js.map +1 -1
  53. package/dist/controllers/context_menu_controller.d.ts +6 -0
  54. package/dist/controllers/context_menu_controller.js +32 -12
  55. package/dist/controllers/context_menu_controller.js.map +1 -1
  56. package/dist/controllers/count_up_controller.js.map +1 -1
  57. package/dist/controllers/countdown_controller.d.ts +30 -1
  58. package/dist/controllers/countdown_controller.js +129 -26
  59. package/dist/controllers/countdown_controller.js.map +1 -1
  60. package/dist/controllers/currency_input_controller.d.ts +77 -16
  61. package/dist/controllers/currency_input_controller.js +221 -67
  62. package/dist/controllers/currency_input_controller.js.map +1 -1
  63. package/dist/controllers/data_grid_controller.d.ts +63 -18
  64. package/dist/controllers/data_grid_controller.js +195 -29
  65. package/dist/controllers/data_grid_controller.js.map +1 -1
  66. package/dist/controllers/date_range_picker_controller.d.ts +41 -6
  67. package/dist/controllers/date_range_picker_controller.js +151 -30
  68. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  69. package/dist/controllers/dialog_controller.d.ts +10 -3
  70. package/dist/controllers/dialog_controller.js +35 -8
  71. package/dist/controllers/dialog_controller.js.map +1 -1
  72. package/dist/controllers/direct_upload_controller.js +22 -4
  73. package/dist/controllers/direct_upload_controller.js.map +1 -1
  74. package/dist/controllers/dirty_form_controller.d.ts +2 -2
  75. package/dist/controllers/dirty_form_controller.js +46 -13
  76. package/dist/controllers/dirty_form_controller.js.map +1 -1
  77. package/dist/controllers/dismissible_controller.js +1 -0
  78. package/dist/controllers/dismissible_controller.js.map +1 -1
  79. package/dist/controllers/drawer_controller.d.ts +18 -10
  80. package/dist/controllers/drawer_controller.js +54 -19
  81. package/dist/controllers/drawer_controller.js.map +1 -1
  82. package/dist/controllers/dropdown_controller.d.ts +9 -3
  83. package/dist/controllers/dropdown_controller.js +36 -9
  84. package/dist/controllers/dropdown_controller.js.map +1 -1
  85. package/dist/controllers/editable_controller.js +34 -0
  86. package/dist/controllers/editable_controller.js.map +1 -1
  87. package/dist/controllers/file_dropzone_controller.js +144 -51
  88. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  89. package/dist/controllers/filter_controller.d.ts +10 -4
  90. package/dist/controllers/filter_controller.js +20 -6
  91. package/dist/controllers/filter_controller.js.map +1 -1
  92. package/dist/controllers/flash_controller.d.ts +26 -6
  93. package/dist/controllers/flash_controller.js +432 -71
  94. package/dist/controllers/flash_controller.js.map +1 -1
  95. package/dist/controllers/focus_controller.js +1 -0
  96. package/dist/controllers/focus_controller.js.map +1 -1
  97. package/dist/controllers/form_field_controller.js +7 -5
  98. package/dist/controllers/form_field_controller.js.map +1 -1
  99. package/dist/controllers/form_validation_controller.js +19 -13
  100. package/dist/controllers/form_validation_controller.js.map +1 -1
  101. package/dist/controllers/frame_loading_controller.js +45 -8
  102. package/dist/controllers/frame_loading_controller.js.map +1 -1
  103. package/dist/controllers/highlight_controller.js +82 -25
  104. package/dist/controllers/highlight_controller.js.map +1 -1
  105. package/dist/controllers/hover_card_controller.d.ts +10 -2
  106. package/dist/controllers/hover_card_controller.js +40 -14
  107. package/dist/controllers/hover_card_controller.js.map +1 -1
  108. package/dist/controllers/idle_controller.d.ts +16 -3
  109. package/dist/controllers/idle_controller.js +90 -5
  110. package/dist/controllers/idle_controller.js.map +1 -1
  111. package/dist/controllers/input_mask_controller.d.ts +5 -2
  112. package/dist/controllers/input_mask_controller.js +65 -9
  113. package/dist/controllers/input_mask_controller.js.map +1 -1
  114. package/dist/controllers/intersection_controller.js +3 -0
  115. package/dist/controllers/intersection_controller.js.map +1 -1
  116. package/dist/controllers/lazy_frame_controller.js +11 -2
  117. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  118. package/dist/controllers/listbox_controller.d.ts +50 -8
  119. package/dist/controllers/listbox_controller.js +203 -45
  120. package/dist/controllers/listbox_controller.js.map +1 -1
  121. package/dist/controllers/local_time_controller.js +10 -5
  122. package/dist/controllers/local_time_controller.js.map +1 -1
  123. package/dist/controllers/masonry_controller.d.ts +3 -3
  124. package/dist/controllers/masonry_controller.js +31 -15
  125. package/dist/controllers/masonry_controller.js.map +1 -1
  126. package/dist/controllers/menu_controller.d.ts +9 -3
  127. package/dist/controllers/menu_controller.js +45 -16
  128. package/dist/controllers/menu_controller.js.map +1 -1
  129. package/dist/controllers/menubar_controller.d.ts +11 -0
  130. package/dist/controllers/menubar_controller.js +58 -24
  131. package/dist/controllers/menubar_controller.js.map +1 -1
  132. package/dist/controllers/meter_controller.js +9 -5
  133. package/dist/controllers/meter_controller.js.map +1 -1
  134. package/dist/controllers/multi_select_controller.d.ts +18 -3
  135. package/dist/controllers/multi_select_controller.js +278 -104
  136. package/dist/controllers/multi_select_controller.js.map +1 -1
  137. package/dist/controllers/navigation_menu_controller.d.ts +11 -0
  138. package/dist/controllers/navigation_menu_controller.js +48 -15
  139. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  140. package/dist/controllers/nested_form_controller.js +37 -8
  141. package/dist/controllers/nested_form_controller.js.map +1 -1
  142. package/dist/controllers/network_status_controller.js +9 -1
  143. package/dist/controllers/network_status_controller.js.map +1 -1
  144. package/dist/controllers/number_input_controller.d.ts +36 -8
  145. package/dist/controllers/number_input_controller.js +124 -21
  146. package/dist/controllers/number_input_controller.js.map +1 -1
  147. package/dist/controllers/optimistic_controller.js +42 -5
  148. package/dist/controllers/optimistic_controller.js.map +1 -1
  149. package/dist/controllers/otp_controller.d.ts +22 -7
  150. package/dist/controllers/otp_controller.js +198 -55
  151. package/dist/controllers/otp_controller.js.map +1 -1
  152. package/dist/controllers/overflow_indicator_controller.d.ts +8 -12
  153. package/dist/controllers/overflow_indicator_controller.js +115 -21
  154. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  155. package/dist/controllers/overflow_menu_controller.d.ts +26 -6
  156. package/dist/controllers/overflow_menu_controller.js +141 -44
  157. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  158. package/dist/controllers/pagination_controller.d.ts +19 -11
  159. package/dist/controllers/pagination_controller.js +74 -28
  160. package/dist/controllers/pagination_controller.js.map +1 -1
  161. package/dist/controllers/password_reveal_controller.d.ts +15 -1
  162. package/dist/controllers/password_reveal_controller.js +59 -2
  163. package/dist/controllers/password_reveal_controller.js.map +1 -1
  164. package/dist/controllers/persist_controller.js +30 -8
  165. package/dist/controllers/persist_controller.js.map +1 -1
  166. package/dist/controllers/pointer_drag_controller.js +131 -52
  167. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  168. package/dist/controllers/popover_controller.d.ts +9 -3
  169. package/dist/controllers/popover_controller.js +45 -11
  170. package/dist/controllers/popover_controller.js.map +1 -1
  171. package/dist/controllers/portal_controller.d.ts +1 -1
  172. package/dist/controllers/portal_controller.js +6 -2
  173. package/dist/controllers/portal_controller.js.map +1 -1
  174. package/dist/controllers/preview_guard_controller.js +16 -1
  175. package/dist/controllers/preview_guard_controller.js.map +1 -1
  176. package/dist/controllers/progress_controller.js +8 -4
  177. package/dist/controllers/progress_controller.js.map +1 -1
  178. package/dist/controllers/radio_group_controller.d.ts +6 -4
  179. package/dist/controllers/radio_group_controller.js +42 -17
  180. package/dist/controllers/radio_group_controller.js.map +1 -1
  181. package/dist/controllers/range_slider_controller.d.ts +49 -1
  182. package/dist/controllers/range_slider_controller.js +88 -42
  183. package/dist/controllers/range_slider_controller.js.map +1 -1
  184. package/dist/controllers/rating_controller.d.ts +14 -3
  185. package/dist/controllers/rating_controller.js +39 -15
  186. package/dist/controllers/rating_controller.js.map +1 -1
  187. package/dist/controllers/read_more_controller.d.ts +24 -2
  188. package/dist/controllers/read_more_controller.js +100 -7
  189. package/dist/controllers/read_more_controller.js.map +1 -1
  190. package/dist/controllers/reading_progress_controller.js +65 -19
  191. package/dist/controllers/reading_progress_controller.js.map +1 -1
  192. package/dist/controllers/relative_time_controller.js +10 -5
  193. package/dist/controllers/relative_time_controller.js.map +1 -1
  194. package/dist/controllers/resizable_controller.d.ts +16 -2
  195. package/dist/controllers/resizable_controller.js +82 -22
  196. package/dist/controllers/resizable_controller.js.map +1 -1
  197. package/dist/controllers/scroll_area_controller.js +75 -27
  198. package/dist/controllers/scroll_area_controller.js.map +1 -1
  199. package/dist/controllers/scroll_restore_controller.js +37 -16
  200. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  201. package/dist/controllers/scroll_visibility_controller.js +49 -30
  202. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  203. package/dist/controllers/scrollspy_controller.d.ts +3 -2
  204. package/dist/controllers/scrollspy_controller.js +71 -26
  205. package/dist/controllers/scrollspy_controller.js.map +1 -1
  206. package/dist/controllers/separator_controller.d.ts +41 -14
  207. package/dist/controllers/separator_controller.js +66 -37
  208. package/dist/controllers/separator_controller.js.map +1 -1
  209. package/dist/controllers/sidebar_controller.d.ts +20 -3
  210. package/dist/controllers/sidebar_controller.js +77 -18
  211. package/dist/controllers/sidebar_controller.js.map +1 -1
  212. package/dist/controllers/skeleton_controller.js +6 -1
  213. package/dist/controllers/skeleton_controller.js.map +1 -1
  214. package/dist/controllers/slider_controller.d.ts +45 -7
  215. package/dist/controllers/slider_controller.js +82 -47
  216. package/dist/controllers/slider_controller.js.map +1 -1
  217. package/dist/controllers/smart_sticky_header_controller.js +60 -26
  218. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  219. package/dist/controllers/sortable_controller.js +17 -2
  220. package/dist/controllers/sortable_controller.js.map +1 -1
  221. package/dist/controllers/spinner_controller.js +10 -2
  222. package/dist/controllers/spinner_controller.js.map +1 -1
  223. package/dist/controllers/step_indicator_controller.d.ts +19 -17
  224. package/dist/controllers/step_indicator_controller.js +18 -17
  225. package/dist/controllers/step_indicator_controller.js.map +1 -1
  226. package/dist/controllers/stepper_controller.d.ts +34 -9
  227. package/dist/controllers/stepper_controller.js +101 -19
  228. package/dist/controllers/stepper_controller.js.map +1 -1
  229. package/dist/controllers/stick_to_bottom_controller.d.ts +19 -0
  230. package/dist/controllers/stick_to_bottom_controller.js +104 -8
  231. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  232. package/dist/controllers/submit_once_controller.d.ts +3 -2
  233. package/dist/controllers/submit_once_controller.js +45 -9
  234. package/dist/controllers/submit_once_controller.js.map +1 -1
  235. package/dist/controllers/switch_controller.d.ts +29 -7
  236. package/dist/controllers/switch_controller.js +101 -10
  237. package/dist/controllers/switch_controller.js.map +1 -1
  238. package/dist/controllers/tabs_controller.d.ts +12 -0
  239. package/dist/controllers/tabs_controller.js +21 -2
  240. package/dist/controllers/tabs_controller.js.map +1 -1
  241. package/dist/controllers/tags_input_controller.d.ts +15 -3
  242. package/dist/controllers/tags_input_controller.js +209 -59
  243. package/dist/controllers/tags_input_controller.js.map +1 -1
  244. package/dist/controllers/textarea_autosize_controller.js +29 -3
  245. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  246. package/dist/controllers/theme_controller.d.ts +20 -3
  247. package/dist/controllers/theme_controller.js +64 -14
  248. package/dist/controllers/theme_controller.js.map +1 -1
  249. package/dist/controllers/time_picker_controller.js +23 -8
  250. package/dist/controllers/time_picker_controller.js.map +1 -1
  251. package/dist/controllers/toast_controller.d.ts +55 -15
  252. package/dist/controllers/toast_controller.js +451 -105
  253. package/dist/controllers/toast_controller.js.map +1 -1
  254. package/dist/controllers/toggle_group_controller.d.ts +49 -7
  255. package/dist/controllers/toggle_group_controller.js +159 -23
  256. package/dist/controllers/toggle_group_controller.js.map +1 -1
  257. package/dist/controllers/toolbar_controller.js +32 -0
  258. package/dist/controllers/toolbar_controller.js.map +1 -1
  259. package/dist/controllers/tooltip_controller.d.ts +8 -0
  260. package/dist/controllers/tooltip_controller.js +39 -13
  261. package/dist/controllers/tooltip_controller.js.map +1 -1
  262. package/dist/controllers/transition_controller.js +4 -0
  263. package/dist/controllers/transition_controller.js.map +1 -1
  264. package/dist/controllers/tree_view_controller.d.ts +39 -8
  265. package/dist/controllers/tree_view_controller.js +169 -16
  266. package/dist/controllers/tree_view_controller.js.map +1 -1
  267. package/dist/index.d.ts +28 -1
  268. package/dist/index.js +5002 -1911
  269. package/dist/index.js.map +1 -1
  270. package/dist/inspector/cli.d.ts +72 -6
  271. package/dist/inspector/cli.js +262 -51
  272. package/dist/inspector/cli.js.map +1 -1
  273. package/dist/inspector/cli_bin.js +309 -51
  274. package/dist/inspector/cli_bin.js.map +1 -1
  275. package/dist/inspector/examples.json +40 -40
  276. package/dist/inspector/manifest.json +529 -61
  277. package/dist/positioning/index.js +2 -0
  278. package/dist/positioning/index.js.map +1 -1
  279. package/package.json +2 -2
@@ -3,11 +3,19 @@ import { Controller } from '@hotwired/stimulus';
3
3
  // src/controllers/input_mask_controller.ts
4
4
 
5
5
  // src/utils/composition_tracker.ts
6
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
7
+ "insertCompositionText",
8
+ "insertFromComposition",
9
+ "deleteCompositionText",
10
+ "deleteByComposition"
11
+ ]);
6
12
  var CompositionTracker = class {
7
13
  #observedTargets = /* @__PURE__ */ new Set();
8
14
  #activeTargets = /* @__PURE__ */ new Set();
9
15
  #onStart;
10
16
  #onEnd;
17
+ /** The field whose confirming `input` is still owed, while the window is open. */
18
+ #confirmedTarget = null;
11
19
  constructor(options = {}) {
12
20
  this.#onStart = options.onStart;
13
21
  this.#onEnd = options.onEnd;
@@ -17,6 +25,7 @@ var CompositionTracker = class {
17
25
  if (this.#observedTargets.has(target)) return;
18
26
  target.addEventListener("compositionstart", this.#handleStart);
19
27
  target.addEventListener("compositionend", this.#handleEnd);
28
+ target.addEventListener("keydown", this.#handleKeydown);
20
29
  this.#observedTargets.add(target);
21
30
  }
22
31
  /** Stops tracking one target and clears any active composition it owned. */
@@ -24,29 +33,52 @@ var CompositionTracker = class {
24
33
  if (!this.#observedTargets.delete(target)) return;
25
34
  target.removeEventListener("compositionstart", this.#handleStart);
26
35
  target.removeEventListener("compositionend", this.#handleEnd);
36
+ target.removeEventListener("keydown", this.#handleKeydown);
27
37
  this.#activeTargets.delete(target);
38
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
28
39
  }
29
40
  /** Releases every listener and clears state so reconnect starts cleanly. */
30
41
  disconnect() {
31
42
  for (const target of this.#observedTargets) {
32
43
  target.removeEventListener("compositionstart", this.#handleStart);
33
44
  target.removeEventListener("compositionend", this.#handleEnd);
45
+ target.removeEventListener("keydown", this.#handleKeydown);
34
46
  }
35
47
  this.#observedTargets.clear();
36
48
  this.#activeTargets.clear();
49
+ this.#confirmedTarget = null;
37
50
  }
38
51
  /** True when lifecycle tracking or the current event reports composition. */
39
52
  isComposing(event) {
40
53
  return this.#activeTargets.size > 0 || event?.isComposing === true;
41
54
  }
55
+ /**
56
+ * Whether `event` is the `input` echoing the composition just confirmed.
57
+ *
58
+ * Asking closes the window either way, so one confirmation is folded at most
59
+ * once and a consumer asks once per `input`.
60
+ */
61
+ consumesConfirmedInput(event) {
62
+ const confirmed = this.#confirmedTarget;
63
+ this.#confirmedTarget = null;
64
+ if (confirmed === null || confirmed !== event.target) return false;
65
+ const inputType = event.inputType;
66
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
67
+ }
42
68
  #handleStart = (event) => {
69
+ this.#confirmedTarget = null;
43
70
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
71
  this.#onStart?.(event);
45
72
  };
46
73
  #handleEnd = (event) => {
47
74
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
75
+ this.#confirmedTarget = event.target;
48
76
  this.#onEnd?.(event);
49
77
  };
78
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
79
+ #handleKeydown = () => {
80
+ this.#confirmedTarget = null;
81
+ };
50
82
  };
51
83
 
52
84
  // src/utils/declared_value.ts
@@ -99,7 +131,7 @@ var DEFAULT_TOKENS = {
99
131
  a: "[A-Za-z]",
100
132
  "*": "[A-Za-z0-9]"
101
133
  };
102
- var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
134
+ var UNMASK_ATTR = "unmask";
103
135
  function acceptedForm(regex, char) {
104
136
  if (regex.test(char)) return char;
105
137
  const half = halfWidthChar(char);
@@ -176,6 +208,10 @@ function tokenCharOffset(tokenFlags, n) {
176
208
  return -1;
177
209
  }
178
210
  var InputMaskController = class extends Controller {
211
+ /** The attribute above, in the namespace this controller is registered under. */
212
+ get #unmaskAttribute() {
213
+ return `data-${this.identifier}-${UNMASK_ATTR}`;
214
+ }
179
215
  static values = {
180
216
  pattern: { type: String, default: "" },
181
217
  tokens: { type: String, default: "" },
@@ -188,8 +224,23 @@ var InputMaskController = class extends Controller {
188
224
  #started = false;
189
225
  /** Validated token map; the hot path never parses the `tokens` declaration. */
190
226
  #tokens = compileTokens("");
191
- /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
192
- #composition = new CompositionTracker({ onEnd: () => this.#reformat("edit") });
227
+ /** The field's text when the running composition began. */
228
+ #textBeforeComposition = "";
229
+ /**
230
+ * Holds mid-composition input so the IME's uncommitted text is never rewritten,
231
+ * and formats the confirmed text once when the composition ends. A composition
232
+ * that ends on the text it began with committed nothing, so a re-format then is
233
+ * the page's: the mask changed while it ran.
234
+ */
235
+ #composition = new CompositionTracker({
236
+ onStart: () => {
237
+ this.#textBeforeComposition = this.element.value;
238
+ },
239
+ onEnd: () => {
240
+ const committedNothing = this.element.value === this.#textBeforeComposition;
241
+ this.#reformat(committedNothing ? "reconcile" : "edit");
242
+ }
243
+ });
193
244
  /** Re-parses the declaration and re-formats under the tokens it now selects. */
194
245
  tokensValueChanged() {
195
246
  this.#tokens = compileTokens(this.tokensValue);
@@ -225,8 +276,11 @@ var InputMaskController = class extends Controller {
225
276
  /**
226
277
  * Core reformat: mask the current value, restore the caret, sync the sink and
227
278
  * the state hooks, and report a moved value under the event `cause` selects.
279
+ *
280
+ * @stimeoRenderRoot
228
281
  */
229
282
  #reformat(cause, deletion = null) {
283
+ if (this.#composition.isComposing()) return;
230
284
  const input = this.element;
231
285
  const raw = input.value;
232
286
  if (this.patternValue === "") {
@@ -311,8 +365,8 @@ var InputMaskController = class extends Controller {
311
365
  * `id` (`data-stimeo--input-mask-unmask="zip"`).
312
366
  * 2. **Nearest container** — otherwise, walking up from the input, the first
313
367
  * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
314
- * find their own sink, and the single form-level sink keeps working
315
- * unchanged. A sink claimed by another input's id is never matched here.
368
+ * find their own sink, and a single form-level sink is found the same
369
+ * way. A sink claimed by another input's id is never matched here.
316
370
  *
317
371
  * Both steps only ever consider the associated form's own controls, so a sink
318
372
  * belonging to a different form (or to none) is never written, while one the
@@ -324,10 +378,10 @@ var InputMaskController = class extends Controller {
324
378
  const candidates = this.#sinkCandidates();
325
379
  const id = this.element.id;
326
380
  if (id.length > 0) {
327
- const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);
381
+ const paired = candidates.find((sink) => sink.getAttribute(this.#unmaskAttribute) === id);
328
382
  if (paired) return paired;
329
383
  }
330
- const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === "");
384
+ const free = candidates.filter((sink) => sink.getAttribute(this.#unmaskAttribute) === "");
331
385
  for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
332
386
  const ancestor = node;
333
387
  const sink = free.find((candidate) => ancestor.contains(candidate));
@@ -344,10 +398,12 @@ var InputMaskController = class extends Controller {
344
398
  #sinkCandidates() {
345
399
  const form = this.element.form;
346
400
  if (form === null) {
347
- return Array.from(document.querySelectorAll(`input[${UNMASK_ATTR}]`));
401
+ return Array.from(
402
+ document.querySelectorAll(`input[${this.#unmaskAttribute}]`)
403
+ );
348
404
  }
349
405
  return Array.from(form.elements).filter(
350
- (element) => element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR)
406
+ (element) => element instanceof HTMLInputElement && element.hasAttribute(this.#unmaskAttribute)
351
407
  );
352
408
  }
353
409
  /** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/declared_value.ts","../../src/utils/focus_candidate.ts","../../src/utils/half_width.ts","../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported on its own for callers that need the disabled-inheritance rule without\n * the rest of {@link canTakeFocus} — a control's availability check, or a `hidden`\n * walk bounded at a widget root, which is a different rule to compose with.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the four conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","/** The distance between a full-width ASCII code point and its half-width twin. */\nconst FULL_WIDTH_SHIFT = 0xfee0;\n\n/**\n * The half-width form of `char`, or `char` itself when it has none.\n *\n * An IME confirming in full-width mode produces U+FF01–U+FF5E (and U+3000 for\n * the space) where the page means ASCII — the digits of a phone number, the\n * letters of a code, the separators between them. Reading those as their ASCII\n * equivalents keeps confirmed text usable instead of discarding it. Characters\n * outside that block, including kana and astral code points, are returned\n * unchanged, so this never rewrites text a consumer meant to keep as typed.\n */\nexport function halfWidthChar(char: string): string {\n if (char >= \"!\" && char <= \"~\") {\n return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);\n }\n return char === \" \" ? \" \" : char;\n}\n\n/** Every full-width ASCII character in `text` rewritten by {@link halfWidthChar}. */\nexport function toHalfWidth(text: string): string {\n let out = \"\";\n for (const char of text) out += halfWidthChar(char);\n return out;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { compileRegExp, parseJsonObject } from \"../utils/declared_value\";\nimport { inheritsFieldsetDisabled } from \"../utils/focus_candidate\";\nimport { halfWidthChar } from \"../utils/half_width\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/** Attribute marking the hidden raw-value sink; its value may name the paired input's `id`. */\nconst UNMASK_ATTR = \"data-stimeo--input-mask-unmask\";\n\n/** Which neighbour a deleting keystroke aimed at, when the platform names one. */\ntype Deletion = \"backward\" | \"forward\";\n\n/** Who moved the value: the user editing the field, or the controller re-deciding it. */\ntype Cause = \"edit\" | \"reconcile\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * The form of `char` this token takes: as typed when the token accepts it, else\n * its half-width form when *that* is what the token accepts, else `null` for a\n * rejected character. Trying the character as typed first keeps a token written\n * for full-width text (or for text an IME leaves as kana) authoritative.\n */\nfunction acceptedForm(regex: RegExp, char: string): string | null {\n if (regex.test(char)) return char;\n const half = halfWidthChar(char);\n return half !== char && regex.test(half) ? half : null;\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. A character an IME confirmed in\n * full-width form fills a token — or consumes a literal — when its half-width\n * form is the one the mask asked for, and the half-width form is what the output\n * carries. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one fills this token (or input runs out).\n let taken: string | null = null;\n while (valueIndex < value.length) {\n taken = acceptedForm(regex, value[valueIndex] ?? \"\");\n if (taken !== null) break;\n valueIndex += 1;\n }\n if (taken === null) continue;\n masked += taken;\n unmasked += taken;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n // The literal is already in the output, so the typed character only has to\n // be the one that renders it — in either width.\n if (halfWidthChar(value[valueIndex] ?? \"\") === halfWidthChar(patternChar)) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * The token map a `tokens` declaration selects: the defaults with the declared\n * single-char regex sources compiled over them.\n *\n * A declaration that is not a JSON object of string sources declares no tokens,\n * leaving the defaults. A source that is not a valid regex is answered per key:\n * a key that has a default keeps that default, and a key that has none is left\n * out so the character stays a literal. Either way the rest of the mask, and the\n * field, keep working instead of going down with the broken source.\n */\nfunction compileTokens(declaration: string): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries(DEFAULT_TOKENS)) {\n const compiled = compileRegExp(source, \"exact\");\n if (compiled) map.set(key, compiled);\n }\n for (const [key, source] of Object.entries(parseTokens(declaration))) {\n const compiled = compileRegExp(source, \"exact\");\n // A broken source declares nothing: the default this key may already carry\n // is what it falls back to, and a key without one holds no token at all.\n if (compiled) map.set(key, compiled);\n }\n return map;\n}\n\n/** The `{ token: source }` pairs a declaration holds; anything else reads as none. */\nfunction parseTokens(declaration: string): Record<string, string> {\n const parsed = parseJsonObject(declaration);\n if (parsed === null) return {};\n\n const tokens: Record<string, string> = {};\n for (const [key, source] of Object.entries(parsed)) {\n if (typeof source === \"string\") tokens[key] = source;\n }\n return tokens;\n}\n\n/** The direction an `input` event deleted in, or `null` when it is not a deletion. */\nfunction deletionOf(event: Event | undefined): Deletion | null {\n const inputType = event instanceof InputEvent ? event.inputType : \"\";\n if (!inputType.startsWith(\"delete\")) return null;\n // Word, line, and content deletions all name their direction; the ones that\n // name none (a cut, a drag-out) took a selection, so read them as backward.\n return inputType.endsWith(\"Forward\") ? \"forward\" : \"backward\";\n}\n\n/** The offset of the `n`-th token char in a masked string, or `-1` when there is none. */\nfunction tokenCharOffset(tokenFlags: readonly boolean[], n: number): number {\n let seen = 0;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) return i;\n }\n return -1;\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; Currency Input owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` and `reconcile` dispatch `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first among\n * the associated form's own controls (an explicit pairing by the sink attribute's\n * value naming the input's `id` wins; otherwise the closest ancestor's value-less\n * sink) — when one form holds several masked inputs, wrap each input+sink pair in a\n * container or pair them by `id`.\n *\n * Formatting never steals keys: it rejects disallowed characters silently and keeps\n * the caret on insert, Backspace, and range replacement (WCAG 2.2 3.3.2 / 1.3.5;\n * the expected format is the consumer's `aria-describedby`). The caret anchors on\n * the token slots the text before it fills, so a rejected character leaves it\n * exactly where it was. Backspace and Delete always progress: when the keystroke\n * only removed literals the mask re-inserts, the significant character it aimed at\n * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is\n * formatted once on `compositionend`.\n *\n * It reflects `data-mask-complete` / `data-mask-empty`, dispatches\n * `stimeo--input-mask:change` only when a user edit moves the committed value, and\n * `stimeo--input-mask:reconcile` when the controller itself decides that value —\n * a server-rendered or restored value normalized on connection, or a re-format\n * after `pattern` / `tokens` / `unmaskToHidden` changed. A malformed `tokens`\n * declaration falls back to the default tokens, and a single broken source falls\n * back to that key's default: it is parsed once in `tokensValueChanged`, and the\n * hot path only ever sees the validated map. A field\n * the user cannot edit (`readonly`, `disabled`, or inside a disabled `<fieldset>`)\n * keeps the value the page authored — the sink and the state hooks still follow it,\n * but the field itself is never rewritten. The composition listener is released on\n * `disconnect()`.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: String, default: \"\" },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare patternValue: string;\n declare tokensValue: string;\n declare unmaskToHiddenValue: boolean;\n\n /** The value this controller last committed, and the baseline both events compare. */\n #lastValue: string | null = null;\n #started = false;\n\n /** Validated token map; the hot path never parses the `tokens` declaration. */\n #tokens = compileTokens(\"\");\n\n /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.#reformat(\"edit\") });\n\n /** Re-parses the declaration and re-formats under the tokens it now selects. */\n tokensValueChanged(): void {\n this.#tokens = compileTokens(this.tokensValue);\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n patternValueChanged(): void {\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */\n unmaskToHiddenValueChanged(): void {\n if (!this.#started) return;\n if (!this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = \"\";\n }\n this.#reformat(\"reconcile\");\n }\n\n override connect(): void {\n this.#started = true;\n this.#composition.observe(this.element);\n // Seed the baseline from the DOM so a value that is already masked stays\n // silent, then re-format any server-rendered/restored value.\n this.#lastValue = this.element.value;\n this.#reformat(\"reconcile\");\n }\n\n override disconnect(): void {\n this.#started = false;\n this.#composition.disconnect();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(event?: Event): void {\n if (this.#composition.isComposing(event as InputEvent | undefined)) return;\n this.#reformat(\"edit\", deletionOf(event));\n }\n\n /**\n * Core reformat: mask the current value, restore the caret, sync the sink and\n * the state hooks, and report a moved value under the event `cause` selects.\n */\n #reformat(cause: Cause, deletion: Deletion | null = null): void {\n const input = this.element;\n const raw = input.value;\n\n // No pattern → act as a pass-through. Without this branch `applyMask` would\n // produce an empty string and blank a misconfigured field's value; the hooks\n // still describe the field so a pattern removed at runtime cannot leave a\n // stale `data-mask-complete` behind.\n if (this.patternValue === \"\") {\n this.#flag(\"data-mask-complete\", false);\n this.#flag(\"data-mask-empty\", raw.length === 0);\n this.#lastValue = raw;\n return;\n }\n\n const caret = input.selectionStart ?? raw.length;\n let anchor = this.#significantBefore(raw.slice(0, caret));\n let result = applyMask(raw, this.patternValue, this.#tokens);\n\n // A deletion that only took literals the mask re-inserts would land back on\n // the value it started from, so that keystroke could never delete anything:\n // drop the significant character it aimed at instead.\n if (deletion !== null && result.masked === this.#lastValue) {\n const target = deletion === \"backward\" ? anchor : anchor + 1;\n const offset = tokenCharOffset(result.tokenFlags, target);\n if (offset >= 0) {\n anchor = target - 1;\n result = applyMask(\n result.masked.slice(0, offset) + result.masked.slice(offset + 1),\n this.patternValue,\n this.#tokens,\n );\n }\n }\n\n // The value of a field the user cannot edit belongs to the page: publish the\n // derived outputs against it, but never rewrite it and never report a move.\n if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {\n this.#publish(result, raw);\n this.#lastValue = raw;\n return;\n }\n\n input.value = result.masked;\n this.#restoreCaret(result.tokenFlags, anchor);\n this.#publish(result, result.masked);\n\n if (result.masked === this.#lastValue) return;\n this.#lastValue = result.masked;\n const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };\n if (cause === \"edit\") this.dispatch(\"change\", { detail });\n else this.dispatch(\"reconcile\", { detail });\n }\n\n /** Syncs the raw-value sink and the state hooks for the `shown` field text. */\n #publish(result: MaskResult, shown: string): void {\n if (this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", shown.length === 0);\n }\n\n /**\n * How many token slots the text before the caret fills. Masking that prefix is\n * what makes a rejected character — or a literal that also matches a token —\n * count exactly as the rendering counts it, so the caret cannot drift.\n */\n #significantBefore(prefix: string): number {\n return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n const offset = tokenCharOffset(tokenFlags, n);\n if (offset < 0) {\n position = tokenFlags.length;\n } else {\n let j = offset + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n }\n }\n try {\n this.element.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`).\n * 2. **Nearest container** — otherwise, walking up from the input, the first\n * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each\n * find their own sink, and the single form-level sink keeps working\n * unchanged. A sink claimed by another input's id is never matched here.\n *\n * Both steps only ever consider the associated form's own controls, so a sink\n * belonging to a different form (or to none) is never written, while one the\n * `form` attribute associates from elsewhere in the document still resolves —\n * for either the input or the sink, neither of which needs to contain or be\n * contained by the form. A form-less input reads the document instead.\n */\n #resolveSink(): HTMLInputElement | null {\n const candidates = this.#sinkCandidates();\n const id = this.element.id;\n if (id.length > 0) {\n const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);\n if (paired) return paired;\n }\n\n // A bare (value-less) attribute is unclaimed, so a sink naming another\n // input's id can never be taken by this fallback.\n const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === \"\");\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const ancestor = node;\n const sink = free.find((candidate) => ancestor.contains(candidate));\n if (sink) return sink;\n }\n return null;\n }\n\n /**\n * Every sink the input's form owns, in document order — one collection read per\n * resolution, so the cost tracks the form's own controls and not the depth of\n * the markup around it. `form.elements` lists controls the `form` attribute\n * associates from anywhere in the document, and lists nothing another form owns.\n */\n #sinkCandidates(): HTMLInputElement[] {\n const form = this.element.form;\n if (form === null) {\n return Array.from(document.querySelectorAll<HTMLInputElement>(`input[${UNMASK_ATTR}]`));\n }\n return Array.from(form.elements).filter(\n (element): element is HTMLInputElement =>\n element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR),\n );\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/declared_value.ts","../../src/utils/focus_candidate.ts","../../src/utils/half_width.ts","../../src/controllers/input_mask_controller.ts"],"names":[],"mappings":"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","file":"input_mask_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Minimal `input` shape needed to tell a composition echo from a fresh edit. */\nexport interface ConfirmedInputSignal {\n readonly target: EventTarget | null;\n readonly inputType?: string;\n}\n\n/**\n * The `inputType` values an engine reports on an `input` that belongs to a\n * composition rather than to an edit the user just made.\n */\nconst COMPOSITION_INPUT_TYPES = new Set([\n \"insertCompositionText\",\n \"insertFromComposition\",\n \"deleteCompositionText\",\n \"deleteByComposition\",\n]);\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * The tracker also owns the other half of that lifecycle: **the `input` some\n * engines send after `compositionend` to echo the text just confirmed.** A\n * consumer that ran `onEnd` and then handled the echo would commit the same\n * confirmation twice, so {@link CompositionTracker.consumesConfirmedInput} tells\n * the echo apart and the consumer returns early on it.\n *\n * **What the echo is not, is the harder half.** Not every engine sends one —\n * Chromium ends a confirmation at `compositionend` with no `input` after it — so\n * a window that waits for the echo and only closes on the next key would swallow\n * the first edit that arrives without one: dictation, autofill, a drop,\n * `insertText`. Two things keep that from happening. The window closes on the\n * next anything — a key, a new composition, any `input`, losing the field — and\n * an `input` that reports an `inputType` outside {@link COMPOSITION_INPUT_TYPES}\n * is an edit, so it is never folded. Only an engine that reports no `inputType`\n * at all is left relying on the window alone.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onInput(event: InputEvent): void {\n * if (this.#composition.consumesConfirmedInput(event)) return;\n * if (this.#composition.isComposing(event)) return;\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n /** The field whose confirming `input` is still owed, while the window is open. */\n #confirmedTarget: EventTarget | null = null;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n target.addEventListener(\"keydown\", this.#handleKeydown);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n target.removeEventListener(\"keydown\", this.#handleKeydown);\n this.#activeTargets.delete(target);\n if (this.#confirmedTarget === target) this.#confirmedTarget = null;\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n target.removeEventListener(\"keydown\", this.#handleKeydown);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n this.#confirmedTarget = null;\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n /**\n * Whether `event` is the `input` echoing the composition just confirmed.\n *\n * Asking closes the window either way, so one confirmation is folded at most\n * once and a consumer asks once per `input`.\n */\n consumesConfirmedInput(event: ConfirmedInputSignal): boolean {\n const confirmed = this.#confirmedTarget;\n this.#confirmedTarget = null;\n if (confirmed === null || confirmed !== event.target) return false;\n // An engine that reports no kind at all leaves `inputType` empty, and the\n // window is then the only signal there is.\n const inputType = event.inputType;\n return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);\n }\n\n readonly #handleStart = (event: Event): void => {\n this.#confirmedTarget = null;\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#confirmedTarget = event.target;\n this.#onEnd?.(event);\n };\n\n /** A key on an observed field opens an edit of its own, so no echo is owed. */\n readonly #handleKeydown = (): void => {\n this.#confirmedTarget = null;\n };\n}\n","/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported on its own for callers that need the disabled-inheritance rule without\n * the rest of {@link canTakeFocus} — a control's availability check, or a `hidden`\n * walk bounded at a widget root, which is a different rule to compose with.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the four conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","/** The distance between a full-width ASCII code point and its half-width twin. */\nconst FULL_WIDTH_SHIFT = 0xfee0;\n\n/**\n * The half-width form of `char`, or `char` itself when it has none.\n *\n * An IME confirming in full-width mode produces U+FF01–U+FF5E (and U+3000 for\n * the space) where the page means ASCII — the digits of a phone number, the\n * letters of a code, the separators between them. Reading those as their ASCII\n * equivalents keeps confirmed text usable instead of discarding it. Characters\n * outside that block, including kana and astral code points, are returned\n * unchanged, so this never rewrites text a consumer meant to keep as typed.\n */\nexport function halfWidthChar(char: string): string {\n if (char >= \"!\" && char <= \"~\") {\n return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);\n }\n return char === \" \" ? \" \" : char;\n}\n\n/** Every full-width ASCII character in `text` rewritten by {@link halfWidthChar}. */\nexport function toHalfWidth(text: string): string {\n let out = \"\";\n for (const char of text) out += halfWidthChar(char);\n return out;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { compileRegExp, parseJsonObject } from \"../utils/declared_value\";\nimport { inheritsFieldsetDisabled } from \"../utils/focus_candidate\";\nimport { halfWidthChar } from \"../utils/half_width\";\n\n/** Default placeholder tokens → single-char regex sources (user tokens merge over these). */\nconst DEFAULT_TOKENS: Readonly<Record<string, string>> = {\n \"9\": \"\\\\d\",\n a: \"[A-Za-z]\",\n \"*\": \"[A-Za-z0-9]\",\n};\n\n/**\n * Suffix of the attribute marking the hidden raw-value sink; its value may name the\n * paired input's `id`.\n */\nconst UNMASK_ATTR = \"unmask\";\n\n/** Which neighbour a deleting keystroke aimed at, when the platform names one. */\ntype Deletion = \"backward\" | \"forward\";\n\n/** Who moved the value: the user editing the field, or the controller re-deciding it. */\ntype Cause = \"edit\" | \"reconcile\";\n\n/** The outcome of applying a fixed mask to a raw string. */\nexport interface MaskResult {\n /** The formatted (masked) string. */\n readonly masked: string;\n /** The significant characters only (literals/separators removed). */\n readonly unmasked: string;\n /** Whether every token slot in the pattern is filled. */\n readonly complete: boolean;\n /** Per-output-char flag: true where the char fills a token (not a literal). */\n readonly tokenFlags: readonly boolean[];\n}\n\n/**\n * The form of `char` this token takes: as typed when the token accepts it, else\n * its half-width form when *that* is what the token accepts, else `null` for a\n * rejected character. Trying the character as typed first keeps a token written\n * for full-width text (or for text an IME leaves as kana) authoritative.\n */\nfunction acceptedForm(regex: RegExp, char: string): string | null {\n if (regex.test(char)) return char;\n const half = halfWidthChar(char);\n return half !== char && regex.test(half) ? half : null;\n}\n\n/**\n * Applies a fixed mask `pattern` to `value` using `tokens` (placeholder → regex).\n * Non-matching input characters are rejected (skipped); literals are auto-inserted\n * and a typed literal that matches is consumed. A character an IME confirmed in\n * full-width form fills a token — or consumes a literal — when its half-width\n * form is the one the mask asked for, and the half-width form is what the output\n * carries. Pure and exported for direct testing.\n */\nexport function applyMask(\n value: string,\n pattern: string,\n tokens: ReadonlyMap<string, RegExp>,\n): MaskResult {\n let masked = \"\";\n let unmasked = \"\";\n const tokenFlags: boolean[] = [];\n let valueIndex = 0;\n let totalTokens = 0;\n\n for (const patternChar of pattern) {\n const regex = tokens.get(patternChar);\n if (regex) totalTokens += 1;\n if (valueIndex >= value.length) continue;\n\n if (regex) {\n // Skip rejected characters until one fills this token (or input runs out).\n let taken: string | null = null;\n while (valueIndex < value.length) {\n taken = acceptedForm(regex, value[valueIndex] ?? \"\");\n if (taken !== null) break;\n valueIndex += 1;\n }\n if (taken === null) continue;\n masked += taken;\n unmasked += taken;\n tokenFlags.push(true);\n valueIndex += 1;\n } else {\n masked += patternChar;\n tokenFlags.push(false);\n // The literal is already in the output, so the typed character only has to\n // be the one that renders it — in either width.\n if (halfWidthChar(value[valueIndex] ?? \"\") === halfWidthChar(patternChar)) valueIndex += 1;\n }\n }\n\n return {\n masked,\n unmasked,\n complete: totalTokens > 0 && unmasked.length === totalTokens,\n tokenFlags,\n };\n}\n\n/**\n * The token map a `tokens` declaration selects: the defaults with the declared\n * single-char regex sources compiled over them.\n *\n * A declaration that is not a JSON object of string sources declares no tokens,\n * leaving the defaults. A source that is not a valid regex is answered per key:\n * a key that has a default keeps that default, and a key that has none is left\n * out so the character stays a literal. Either way the rest of the mask, and the\n * field, keep working instead of going down with the broken source.\n */\nfunction compileTokens(declaration: string): Map<string, RegExp> {\n const map = new Map<string, RegExp>();\n for (const [key, source] of Object.entries(DEFAULT_TOKENS)) {\n const compiled = compileRegExp(source, \"exact\");\n if (compiled) map.set(key, compiled);\n }\n for (const [key, source] of Object.entries(parseTokens(declaration))) {\n const compiled = compileRegExp(source, \"exact\");\n // A broken source declares nothing: the default this key may already carry\n // is what it falls back to, and a key without one holds no token at all.\n if (compiled) map.set(key, compiled);\n }\n return map;\n}\n\n/** The `{ token: source }` pairs a declaration holds; anything else reads as none. */\nfunction parseTokens(declaration: string): Record<string, string> {\n const parsed = parseJsonObject(declaration);\n if (parsed === null) return {};\n\n const tokens: Record<string, string> = {};\n for (const [key, source] of Object.entries(parsed)) {\n if (typeof source === \"string\") tokens[key] = source;\n }\n return tokens;\n}\n\n/** The direction an `input` event deleted in, or `null` when it is not a deletion. */\nfunction deletionOf(event: Event | undefined): Deletion | null {\n const inputType = event instanceof InputEvent ? event.inputType : \"\";\n if (!inputType.startsWith(\"delete\")) return null;\n // Word, line, and content deletions all name their direction; the ones that\n // name none (a cut, a drag-out) took a selection, so read them as backward.\n return inputType.endsWith(\"Forward\") ? \"forward\" : \"backward\";\n}\n\n/** The offset of the `n`-th token char in a masked string, or `-1` when there is none. */\nfunction tokenCharOffset(tokenFlags: readonly boolean[], n: number): number {\n let seen = 0;\n for (let i = 0; i < tokenFlags.length; i += 1) {\n if (!tokenFlags[i]) continue;\n seen += 1;\n if (seen === n) return i;\n }\n return -1;\n}\n\n/**\n * Headless **input mask** — formats a field in place against a fixed pattern\n * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,\n * rejecting invalid characters, and syncing the raw value to a hidden field. No\n * dedicated APG pattern; `stimeo--currency-input` owns money-specific formatting.\n *\n * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):\n * <input type=\"text\" inputmode=\"numeric\"\n * data-controller=\"stimeo--input-mask\"\n * data-stimeo--input-mask-pattern-value=\"999-9999\"\n * data-action=\"input->stimeo--input-mask#format\">\n * <input type=\"hidden\" name=\"zip\" data-stimeo--input-mask-unmask>\n *\n * `change` and `reconcile` dispatch `{ masked, unmasked, complete }`.\n *\n * @remarks\n * Behavior only and **idempotent** — the formatted value lives only in the input and\n * the hidden field (no module-scope state), so `connect()` re-formats the existing\n * value and is stable across Turbo restore/morph. The controller sits on the\n * `<input>` itself (a void element), so the raw-value sink is not a Stimulus target;\n * it is the `[data-stimeo--input-mask-unmask]` field resolved nearest-first among\n * the associated form's own controls (an explicit pairing by the sink attribute's\n * value naming the input's `id` wins; otherwise the closest ancestor's value-less\n * sink) — when one form holds several masked inputs, wrap each input+sink pair in a\n * container or pair them by `id`.\n *\n * Formatting never steals keys: it rejects disallowed characters silently and keeps\n * the caret on insert, Backspace, and range replacement (WCAG 2.2 3.3.2 / 1.3.5;\n * the expected format is the consumer's `aria-describedby`). The caret anchors on\n * the token slots the text before it fills, so a rejected character leaves it\n * exactly where it was. Backspace and Delete always progress: when the keystroke\n * only removed literals the mask re-inserts, the significant character it aimed at\n * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is\n * formatted once on `compositionend`. A `pattern` / `tokens` / `unmaskToHidden`\n * change that arrives mid-composition leaves the uncommitted text alone and applies\n * with that same format: as the user's `change` when the composition committed\n * text, as `reconcile` when it ended on the text it began with.\n *\n * It reflects `data-mask-complete` / `data-mask-empty`, dispatches\n * `stimeo--input-mask:change` only when a user edit moves the committed value, and\n * `stimeo--input-mask:reconcile` when the controller itself decides that value —\n * a server-rendered or restored value normalized on connection, or a re-format\n * after `pattern` / `tokens` / `unmaskToHidden` changed. A malformed `tokens`\n * declaration falls back to the default tokens, and a single broken source falls\n * back to that key's default: it is parsed once in `tokensValueChanged`, and the\n * hot path only ever sees the validated map. A field\n * the user cannot edit (`readonly`, `disabled`, or inside a disabled `<fieldset>`)\n * keeps the value the page authored — the sink and the state hooks still follow it,\n * but the field itself is never rewritten. The composition listener is released on\n * `disconnect()`.\n */\nexport class InputMaskController extends Controller<HTMLInputElement> {\n /** The attribute above, in the namespace this controller is registered under. */\n get #unmaskAttribute(): string {\n return `data-${this.identifier}-${UNMASK_ATTR}`;\n }\n\n static override values = {\n pattern: { type: String, default: \"\" },\n tokens: { type: String, default: \"\" },\n unmaskToHidden: { type: Boolean, default: true },\n };\n static actions = [\"format\"] as const;\n static events = [\"change\", \"reconcile\"] as const;\n\n declare patternValue: string;\n declare tokensValue: string;\n declare unmaskToHiddenValue: boolean;\n\n /** The value this controller last committed, and the baseline both events compare. */\n #lastValue: string | null = null;\n #started = false;\n\n /** Validated token map; the hot path never parses the `tokens` declaration. */\n #tokens = compileTokens(\"\");\n\n /** The field's text when the running composition began. */\n #textBeforeComposition = \"\";\n\n /**\n * Holds mid-composition input so the IME's uncommitted text is never rewritten,\n * and formats the confirmed text once when the composition ends. A composition\n * that ends on the text it began with committed nothing, so a re-format then is\n * the page's: the mask changed while it ran.\n */\n readonly #composition = new CompositionTracker({\n onStart: () => {\n this.#textBeforeComposition = this.element.value;\n },\n onEnd: () => {\n const committedNothing = this.element.value === this.#textBeforeComposition;\n this.#reformat(committedNothing ? \"reconcile\" : \"edit\");\n },\n });\n\n /** Re-parses the declaration and re-formats under the tokens it now selects. */\n tokensValueChanged(): void {\n this.#tokens = compileTokens(this.tokensValue);\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n patternValueChanged(): void {\n if (this.#started) this.#reformat(\"reconcile\");\n }\n\n /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */\n unmaskToHiddenValueChanged(): void {\n if (!this.#started) return;\n if (!this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = \"\";\n }\n this.#reformat(\"reconcile\");\n }\n\n override connect(): void {\n this.#started = true;\n this.#composition.observe(this.element);\n // Seed the baseline from the DOM so a value that is already masked stays\n // silent, then re-format any server-rendered/restored value.\n this.#lastValue = this.element.value;\n this.#reformat(\"reconcile\");\n }\n\n override disconnect(): void {\n this.#started = false;\n this.#composition.disconnect();\n }\n\n /** Formats the field on input, preserving the caret. Bound via `data-action`. */\n format(event?: Event): void {\n if (this.#composition.isComposing(event as InputEvent | undefined)) return;\n this.#reformat(\"edit\", deletionOf(event));\n }\n\n /**\n * Core reformat: mask the current value, restore the caret, sync the sink and\n * the state hooks, and report a moved value under the event `cause` selects.\n *\n * @stimeoRenderRoot\n */\n #reformat(cause: Cause, deletion: Deletion | null = null): void {\n // The IME owns the text until it commits; the composition's end re-formats it.\n if (this.#composition.isComposing()) return;\n const input = this.element;\n const raw = input.value;\n\n // No pattern → act as a pass-through. Without this branch `applyMask` would\n // produce an empty string and blank a misconfigured field's value; the hooks\n // still describe the field so a pattern removed at runtime cannot leave a\n // stale `data-mask-complete` behind.\n if (this.patternValue === \"\") {\n this.#flag(\"data-mask-complete\", false);\n this.#flag(\"data-mask-empty\", raw.length === 0);\n this.#lastValue = raw;\n return;\n }\n\n const caret = input.selectionStart ?? raw.length;\n let anchor = this.#significantBefore(raw.slice(0, caret));\n let result = applyMask(raw, this.patternValue, this.#tokens);\n\n // A deletion that only took literals the mask re-inserts would land back on\n // the value it started from, so that keystroke could never delete anything:\n // drop the significant character it aimed at instead.\n if (deletion !== null && result.masked === this.#lastValue) {\n const target = deletion === \"backward\" ? anchor : anchor + 1;\n const offset = tokenCharOffset(result.tokenFlags, target);\n if (offset >= 0) {\n anchor = target - 1;\n result = applyMask(\n result.masked.slice(0, offset) + result.masked.slice(offset + 1),\n this.patternValue,\n this.#tokens,\n );\n }\n }\n\n // The value of a field the user cannot edit belongs to the page: publish the\n // derived outputs against it, but never rewrite it and never report a move.\n if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {\n this.#publish(result, raw);\n this.#lastValue = raw;\n return;\n }\n\n input.value = result.masked;\n this.#restoreCaret(result.tokenFlags, anchor);\n this.#publish(result, result.masked);\n\n if (result.masked === this.#lastValue) return;\n this.#lastValue = result.masked;\n const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };\n if (cause === \"edit\") this.dispatch(\"change\", { detail });\n else this.dispatch(\"reconcile\", { detail });\n }\n\n /** Syncs the raw-value sink and the state hooks for the `shown` field text. */\n #publish(result: MaskResult, shown: string): void {\n if (this.unmaskToHiddenValue) {\n const sink = this.#resolveSink();\n if (sink) sink.value = result.unmasked;\n }\n this.#flag(\"data-mask-complete\", result.complete);\n this.#flag(\"data-mask-empty\", shown.length === 0);\n }\n\n /**\n * How many token slots the text before the caret fills. Masking that prefix is\n * what makes a rejected character — or a literal that also matches a token —\n * count exactly as the rendering counts it, so the caret cannot drift.\n */\n #significantBefore(prefix: string): number {\n return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;\n }\n\n /** Places the caret after the `n`-th token char (skipping following literals). */\n #restoreCaret(tokenFlags: readonly boolean[], n: number): void {\n let position: number;\n if (n <= 0) {\n // Sit after any leading literals, before the first token slot.\n let i = 0;\n while (i < tokenFlags.length && !tokenFlags[i]) i += 1;\n position = i;\n } else {\n const offset = tokenCharOffset(tokenFlags, n);\n if (offset < 0) {\n position = tokenFlags.length;\n } else {\n let j = offset + 1;\n while (j < tokenFlags.length && !tokenFlags[j]) j += 1;\n position = j;\n }\n }\n try {\n this.element.setSelectionRange(position, position);\n } catch {\n /* selection unsupported for this input type — value formatting still applies */\n }\n }\n\n /**\n * The hidden raw-value sink for this input, resolved so several masked inputs\n * can coexist in one form:\n *\n * 1. **Explicit pairing** — a sink whose attribute value names this input's\n * `id` (`data-stimeo--input-mask-unmask=\"zip\"`).\n * 2. **Nearest container** — otherwise, walking up from the input, the first\n * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each\n * find their own sink, and a single form-level sink is found the same\n * way. A sink claimed by another input's id is never matched here.\n *\n * Both steps only ever consider the associated form's own controls, so a sink\n * belonging to a different form (or to none) is never written, while one the\n * `form` attribute associates from elsewhere in the document still resolves —\n * for either the input or the sink, neither of which needs to contain or be\n * contained by the form. A form-less input reads the document instead.\n */\n #resolveSink(): HTMLInputElement | null {\n const candidates = this.#sinkCandidates();\n const id = this.element.id;\n if (id.length > 0) {\n const paired = candidates.find((sink) => sink.getAttribute(this.#unmaskAttribute) === id);\n if (paired) return paired;\n }\n\n // A bare (value-less) attribute is unclaimed, so a sink naming another\n // input's id can never be taken by this fallback.\n const free = candidates.filter((sink) => sink.getAttribute(this.#unmaskAttribute) === \"\");\n for (let node = this.element.parentElement; node !== null; node = node.parentElement) {\n const ancestor = node;\n const sink = free.find((candidate) => ancestor.contains(candidate));\n if (sink) return sink;\n }\n return null;\n }\n\n /**\n * Every sink the input's form owns, in document order — one collection read per\n * resolution, so the cost tracks the form's own controls and not the depth of\n * the markup around it. `form.elements` lists controls the `form` attribute\n * associates from anywhere in the document, and lists nothing another form owns.\n */\n #sinkCandidates(): HTMLInputElement[] {\n const form = this.element.form;\n if (form === null) {\n return Array.from(\n document.querySelectorAll<HTMLInputElement>(`input[${this.#unmaskAttribute}]`),\n );\n }\n return Array.from(form.elements).filter(\n (element): element is HTMLInputElement =>\n element instanceof HTMLInputElement && element.hasAttribute(this.#unmaskAttribute),\n );\n }\n\n /** Sets a boolean `data-*` flag to `\"true\"` when `on`, else removes it. */\n #flag(name: string, on: boolean): void {\n if (on) this.element.setAttribute(name, \"true\");\n else this.element.removeAttribute(name);\n }\n}\n"]}
@@ -204,6 +204,9 @@ var IntersectionController = class extends Controller {
204
204
  * so the observer's initial callback fires `enter` for an element that starts
205
205
  * visible but stays silent after a cache restore that already recorded it.
206
206
  * An initial not-visible state is established silently (no `exit`).
207
+ *
208
+ * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it
209
+ * writes follows the entry.
207
210
  */
208
211
  #syncIntersecting(intersecting, ratio, entry) {
209
212
  const previous = this.element.getAttribute("data-intersecting");
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block for\n * scroll-driven behavior — loading more on approach, \"animate when visible\"\n * (compose it with `stimeo--count-up`), progress and sticky-header work — so a\n * consumer does not write its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). `threshold` is re-read when Turbo morphs\n * the attribute in place, and a `rootSelector` that does not parse observes the\n * viewport rather than leaving the element unobserved. Without\n * `IntersectionObserver` (very old browsers) the controller stays inert —\n * consumers keep whatever no-JS fallback their markup provides. The observer is\n * disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#observe();\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing\n * rewrites the attribute in place without a reconnect, and the line is what\n * the intersection callback compares every ratio against, so a value frozen at\n * connect time would decide `data-intersecting` wrongly for the rest of the\n * page's life. Nothing to rebuild before the first `connect()`; after a spent\n * one-shot the watcher is deliberately stopped, and re-observing would deliver\n * the current state and fire `enter` a second time.\n */\n thresholdValueChanged(): void {\n if (this.#watcher.active) this.#observe();\n }\n\n /** (Re)installs the observer from the current Values. */\n #observe(): void {\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n return rect.top < (entry.rootBounds?.top ?? 0);\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n *\n * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block for\n * scroll-driven behavior — loading more on approach, \"animate when visible\"\n * (compose it with `stimeo--count-up`), progress and sticky-header work — so a\n * consumer does not write its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). `threshold` is re-read when Turbo morphs\n * the attribute in place, and a `rootSelector` that does not parse observes the\n * viewport rather than leaving the element unobserved. Without\n * `IntersectionObserver` (very old browsers) the controller stays inert —\n * consumers keep whatever no-JS fallback their markup provides. The observer is\n * disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#observe();\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing\n * rewrites the attribute in place without a reconnect, and the line is what\n * the intersection callback compares every ratio against, so a value frozen at\n * connect time would decide `data-intersecting` wrongly for the rest of the\n * page's life. Nothing to rebuild before the first `connect()`; after a spent\n * one-shot the watcher is deliberately stopped, and re-observing would deliver\n * the current state and fire `enter` a second time.\n */\n thresholdValueChanged(): void {\n if (this.#watcher.active) this.#observe();\n }\n\n /** (Re)installs the observer from the current Values. */\n #observe(): void {\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n *\n * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it\n * writes follows the entry.\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n return rect.top < (entry.rootBounds?.top ?? 0);\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n *\n * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
@@ -192,7 +192,12 @@ var LazyFrameController = class extends Controller {
192
192
  if (this.onceValue || !returning) return;
193
193
  this.#reload();
194
194
  }
195
- /** Starts the load by writing the held URL to `src`. */
195
+ /**
196
+ * Starts the load by writing the held URL to `src`.
197
+ *
198
+ * @stimeoRuntimeOnly `url` is the address this one load fetches and `once` decides whether it
199
+ * stops the watcher.
200
+ */
196
201
  #load() {
197
202
  if (!this.urlValue) return;
198
203
  this.#loaded = true;
@@ -202,7 +207,11 @@ var LazyFrameController = class extends Controller {
202
207
  this.element.removeEventListener("focusin", this.#onFocus);
203
208
  if (this.onceValue) this.#watcher.stop();
204
209
  }
205
- /** Re-entry while `once` is off: fetch the held URL again. */
210
+ /**
211
+ * Re-entry while `once` is off: fetch the held URL again.
212
+ *
213
+ * @stimeoRuntimeOnly `url` is the address this one re-entry reloads.
214
+ */
206
215
  #reload() {
207
216
  const src = this.element.getAttribute("src");
208
217
  if (this.urlValue && this.urlValue !== src) {