stimeo-ui 0.16.0 → 0.18.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,87 +1,190 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/input_mask_controller.ts
4
-
5
- // src/utils/composition_tracker.ts
6
3
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
7
4
  "insertCompositionText",
8
5
  "insertFromComposition",
9
6
  "deleteCompositionText",
10
7
  "deleteByComposition"
11
8
  ]);
9
+ function isCompositionInputType(inputType) {
10
+ return inputType !== void 0 && COMPOSITION_INPUT_TYPES.has(inputType);
11
+ }
12
+ var BEFORE_MORPH_ATTRIBUTE = "turbo:before-morph-attribute";
13
+ var textOf = (element) => element.value;
14
+ var holdsFocus = (element) => element instanceof Node && element.getRootNode().activeElement === element;
12
15
  var CompositionTracker = class {
13
16
  #observedTargets = /* @__PURE__ */ new Set();
14
- #activeTargets = /* @__PURE__ */ new Set();
17
+ #composition = null;
15
18
  #onStart;
16
19
  #onEnd;
17
- /** The field whose confirming `input` is still owed, while the window is open. */
20
+ #onAbandon;
18
21
  #confirmedTarget = null;
22
+ #keyComposing = false;
23
+ #connected = false;
24
+ #abandoned = null;
19
25
  constructor(options = {}) {
20
26
  this.#onStart = options.onStart;
21
27
  this.#onEnd = options.onEnd;
28
+ this.#onAbandon = options.onAbandon;
22
29
  }
23
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
24
30
  observe(target) {
31
+ this.#connected = true;
25
32
  if (this.#observedTargets.has(target)) return;
26
33
  target.addEventListener("compositionstart", this.#handleStart);
27
34
  target.addEventListener("compositionend", this.#handleEnd);
28
- target.addEventListener("keydown", this.#handleKeydown);
35
+ target.addEventListener("keydown", this.#handleKeydown, true);
36
+ target.addEventListener("beforeinput", this.#handleBeforeInput, true);
37
+ target.addEventListener("input", this.#handleInput, true);
38
+ target.addEventListener("blur", this.#handleBlur, true);
39
+ target.addEventListener(BEFORE_MORPH_ATTRIBUTE, this.#handleBeforeMorphAttribute);
29
40
  this.#observedTargets.add(target);
30
41
  }
31
- /** Stops tracking one target and clears any active composition it owned. */
32
42
  unobserve(target) {
33
43
  if (!this.#observedTargets.delete(target)) return;
34
- target.removeEventListener("compositionstart", this.#handleStart);
35
- target.removeEventListener("compositionend", this.#handleEnd);
36
- target.removeEventListener("keydown", this.#handleKeydown);
37
- this.#activeTargets.delete(target);
44
+ this.#release(target);
45
+ if (this.#composition?.target === target) this.#letGo();
38
46
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
39
47
  }
40
- /** Releases every listener and clears state so reconnect starts cleanly. */
41
48
  disconnect() {
42
- for (const target of this.#observedTargets) {
43
- target.removeEventListener("compositionstart", this.#handleStart);
44
- target.removeEventListener("compositionend", this.#handleEnd);
45
- target.removeEventListener("keydown", this.#handleKeydown);
46
- }
49
+ this.#connected = false;
50
+ for (const target of this.#observedTargets) this.#release(target);
47
51
  this.#observedTargets.clear();
48
- this.#activeTargets.clear();
52
+ this.#letGo();
49
53
  this.#confirmedTarget = null;
54
+ this.#keyComposing = false;
55
+ this.#abandoned = null;
50
56
  }
51
- /** True when lifecycle tracking or the current event reports composition. */
52
57
  isComposing(event) {
53
- return this.#activeTargets.size > 0 || event?.isComposing === true;
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
- */
58
+ this.#settle();
59
+ return event?.isComposing === true || this.#composition !== null;
60
+ }
61
+ composingElement() {
62
+ this.#settle();
63
+ return this.#composition?.element ?? null;
64
+ }
65
+ owns(event) {
66
+ const element = this.composingElement();
67
+ return event.isComposing === true || element !== null && element === event.target;
68
+ }
61
69
  consumesConfirmedInput(event) {
62
70
  const confirmed = this.#confirmedTarget;
63
71
  this.#confirmedTarget = null;
64
72
  if (confirmed === null || confirmed !== event.target) return false;
65
73
  const inputType = event.inputType;
66
- return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
74
+ return !inputType || isCompositionInputType(inputType);
75
+ }
76
+ #release(target) {
77
+ target.removeEventListener("compositionstart", this.#handleStart);
78
+ target.removeEventListener("compositionend", this.#handleEnd);
79
+ target.removeEventListener("keydown", this.#handleKeydown, true);
80
+ target.removeEventListener("beforeinput", this.#handleBeforeInput, true);
81
+ target.removeEventListener("input", this.#handleInput, true);
82
+ target.removeEventListener("blur", this.#handleBlur, true);
83
+ target.removeEventListener(BEFORE_MORPH_ATTRIBUTE, this.#handleBeforeMorphAttribute);
67
84
  }
85
+ #letGo() {
86
+ const composition = this.#composition;
87
+ if (composition === null) return;
88
+ if (!holdsFocus(composition.element)) {
89
+ this.#composition = null;
90
+ return;
91
+ }
92
+ queueMicrotask(() => {
93
+ if (this.#composition !== composition || this.#observedTargets.has(composition.target)) {
94
+ return;
95
+ }
96
+ this.#composition = null;
97
+ if (!this.#connected) return;
98
+ this.#abandoned = { element: composition.element, inPlace: true };
99
+ this.#runAbandon();
100
+ });
101
+ }
102
+ #settle() {
103
+ const composition = this.#composition;
104
+ if (composition === null) return;
105
+ const { target, element, text } = composition;
106
+ if (target instanceof Node && element instanceof Node && !target.contains(element)) {
107
+ this.#composition = null;
108
+ } else if (textOf(element) !== text) {
109
+ this.#abandon(false);
110
+ }
111
+ }
112
+ #abandon(inPlace) {
113
+ this.#owe(inPlace);
114
+ this.#composition = null;
115
+ queueMicrotask(this.#runAbandon);
116
+ }
117
+ #owe(inPlace) {
118
+ const composition = this.#composition;
119
+ if (composition !== null) this.#abandoned = { element: composition.element, inPlace };
120
+ }
121
+ #runAbandon = () => {
122
+ const abandoned = this.#abandoned;
123
+ if (abandoned === null) return;
124
+ this.#abandoned = null;
125
+ this.#onAbandon?.(abandoned.element, abandoned.inPlace);
126
+ };
68
127
  #handleStart = (event) => {
69
128
  this.#confirmedTarget = null;
70
- if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
129
+ this.#settle();
130
+ this.#owe(true);
131
+ const target = event.currentTarget;
132
+ const element = event.target;
133
+ this.#composition = { target, element, text: textOf(element) };
134
+ this.#runAbandon();
71
135
  this.#onStart?.(event);
72
136
  };
73
137
  #handleEnd = (event) => {
74
- if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
138
+ this.#runAbandon();
139
+ if (this.#composition?.target === event.currentTarget) this.#composition = null;
75
140
  this.#confirmedTarget = event.target;
76
141
  this.#onEnd?.(event);
77
142
  };
78
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
79
- #handleKeydown = () => {
143
+ #handleKeydown = (event) => {
144
+ this.#settle();
145
+ this.#runAbandon();
80
146
  this.#confirmedTarget = null;
147
+ this.#keyComposing = event.isComposing === true;
148
+ };
149
+ #handleBeforeInput = (event) => {
150
+ const keyComposing = this.#keyComposing;
151
+ this.#keyComposing = false;
152
+ this.#settle();
153
+ const { isComposing, inputType } = event;
154
+ if (this.#composition !== null && !keyComposing && !isComposing && inputType && !isCompositionInputType(inputType)) {
155
+ this.#abandon(true);
156
+ }
157
+ this.#runAbandon();
158
+ };
159
+ #handleInput = (event) => {
160
+ const composition = this.#composition;
161
+ const { isTrusted, isComposing, inputType } = event;
162
+ if (composition !== null && event.target === composition.element && (isTrusted || isComposing || isCompositionInputType(inputType))) {
163
+ composition.text = textOf(composition.element);
164
+ } else {
165
+ this.#settle();
166
+ }
167
+ this.#runAbandon();
168
+ };
169
+ #handleBlur = (event) => {
170
+ this.#settle();
171
+ this.#runAbandon();
172
+ const composition = this.#composition;
173
+ if (composition === null || composition.target === composition.element) return;
174
+ const { element } = composition;
175
+ if (!(element instanceof Node) || event.target !== element) return;
176
+ const root = element.getRootNode();
177
+ if (root.activeElement !== element) this.#composition = null;
178
+ };
179
+ #handleBeforeMorphAttribute = (event) => {
180
+ this.#settle();
181
+ const composition = this.#composition;
182
+ if (composition?.target !== event.currentTarget || event.target !== composition.element) return;
183
+ const detail = event.detail;
184
+ if (detail?.attributeName === "value") event.preventDefault();
81
185
  };
82
186
  };
83
187
 
84
- // src/utils/declared_value.ts
85
188
  function parseDeclared(raw, parse, fallback) {
86
189
  try {
87
190
  return parse(raw);
@@ -105,7 +208,43 @@ function parseJsonObject(raw) {
105
208
  return parsed;
106
209
  }
107
210
 
108
- // src/utils/focus_candidate.ts
211
+ var DetachGate = class _DetachGate {
212
+ #pending = false;
213
+ get pending() {
214
+ return this.#pending;
215
+ }
216
+ static isDetached(host) {
217
+ return !host.element.isConnected || !_DetachGate.#listed(host);
218
+ }
219
+ disconnected(host, teardown) {
220
+ const carried = _DetachGate.#carried(host);
221
+ if (!carried && _DetachGate.isDetached(host)) {
222
+ this.#pending = false;
223
+ teardown();
224
+ return;
225
+ }
226
+ this.#pending = true;
227
+ const probe = () => {
228
+ if (!this.#pending) return;
229
+ this.#pending = false;
230
+ teardown();
231
+ };
232
+ if (carried) setTimeout(probe, 0);
233
+ else queueMicrotask(probe);
234
+ }
235
+ static #carried(host) {
236
+ const element = host.element;
237
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
238
+ }
239
+ static #listed(host) {
240
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
241
+ return tokens.includes(host.identifier);
242
+ }
243
+ cancel() {
244
+ this.#pending = false;
245
+ }
246
+ };
247
+
109
248
  function inheritsFieldsetDisabled(control) {
110
249
  let fieldset = control.closest("fieldset[disabled]");
111
250
  while (fieldset) {
@@ -116,7 +255,120 @@ function inheritsFieldsetDisabled(control) {
116
255
  return false;
117
256
  }
118
257
 
119
- // src/utils/half_width.ts
258
+ function sharedRegistry(key, create) {
259
+ const symbol = Symbol.for(key);
260
+ const scope = globalThis;
261
+ const existing = scope[symbol];
262
+ if (existing !== void 0) return existing;
263
+ const registry = create();
264
+ Object.defineProperty(globalThis, symbol, { value: registry });
265
+ return registry;
266
+ }
267
+
268
+ var PREFIX = "__stimeo_raw_v1_";
269
+ var documents = sharedRegistry(
270
+ "stimeo-ui.form-data-projection.registry.v1",
271
+ () => /* @__PURE__ */ new WeakMap()
272
+ );
273
+ function decodedName(key) {
274
+ if (!key.startsWith(PREFIX)) return null;
275
+ const separator = PREFIX.length + 32;
276
+ if (key[separator] !== ":") return null;
277
+ try {
278
+ const name = JSON.parse(key.slice(separator + 1));
279
+ return typeof name === "string" ? name : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+ function projectedFieldName(name) {
285
+ return decodedName(name) ?? name;
286
+ }
287
+ function forDocument(document2) {
288
+ const existing = documents.get(document2);
289
+ if (existing) return existing;
290
+ const state = {
291
+ fields: /* @__PURE__ */ new Map(),
292
+ byField: /* @__PURE__ */ new WeakMap(),
293
+ handle: (event) => {
294
+ const data = event.formData;
295
+ if (!data) return;
296
+ const entries = [...data.entries()];
297
+ let changed = false;
298
+ const projected = entries.map(([key, value]) => {
299
+ const name = decodedName(key);
300
+ if (name === null) return [key, value];
301
+ changed = true;
302
+ const field = state.fields.get(key);
303
+ const live = field && field.field.name === key && field.field.form === event.target;
304
+ return [name, live ? field.project(value) : ""];
305
+ });
306
+ if (!changed) return;
307
+ for (const key of new Set(data.keys())) data.delete(key);
308
+ for (const [name, value] of projected) data.append(name, value);
309
+ }
310
+ };
311
+ documents.set(document2, state);
312
+ document2.addEventListener("formdata", state.handle, { capture: true });
313
+ return state;
314
+ }
315
+ function prepareFormDataProjection(form) {
316
+ const state = forDocument(form.ownerDocument);
317
+ form.addEventListener("formdata", state.handle, { capture: true });
318
+ }
319
+ var FormDataProjection = class {
320
+ #held = null;
321
+ hold(field, project) {
322
+ const held = this.#held;
323
+ if (held?.field === field && held.key === field.name && held.document === field.ownerDocument) {
324
+ if (field.form) prepareFormDataProjection(field.form);
325
+ return;
326
+ }
327
+ this.release();
328
+ const name = projectedFieldName(field.name);
329
+ if (name === "" || field.type === "hidden" && name.toLowerCase() === "_charset_") return;
330
+ const state = forDocument(field.ownerDocument);
331
+ if (field.form) prepareFormDataProjection(field.form);
332
+ const previous = state.byField.get(field);
333
+ if (previous) {
334
+ state.fields.delete(previous.key);
335
+ field.removeEventListener("turbo:morph-element", previous.restore);
336
+ }
337
+ const nonce = Array.from(
338
+ crypto.getRandomValues(new Uint8Array(16)),
339
+ (value) => value.toString(16).padStart(2, "0")
340
+ ).join("");
341
+ const key = `${PREFIX}${nonce}:${JSON.stringify(name)}`;
342
+ const projection = {
343
+ field,
344
+ key,
345
+ name,
346
+ project,
347
+ document: field.ownerDocument,
348
+ state,
349
+ restore: () => {
350
+ if (field.name === name) field.name = key;
351
+ }
352
+ };
353
+ state.fields.set(key, projection);
354
+ state.byField.set(field, projection);
355
+ this.#held = projection;
356
+ field.name = key;
357
+ field.addEventListener("turbo:morph-element", projection.restore);
358
+ }
359
+ release() {
360
+ const held = this.#held;
361
+ this.#held = null;
362
+ if (held === null) return;
363
+ held.field.removeEventListener("turbo:morph-element", held.restore);
364
+ const state = held.state;
365
+ if (state.byField.get(held.field) !== held) return;
366
+ state.fields.delete(held.key);
367
+ state.byField.delete(held.field);
368
+ if (held.field.name === held.key) held.field.name = held.name;
369
+ }
370
+ };
371
+
120
372
  var FULL_WIDTH_SHIFT = 65248;
121
373
  function halfWidthChar(char) {
122
374
  if (char >= "\uFF01" && char <= "\uFF5E") {
@@ -125,13 +377,123 @@ function halfWidthChar(char) {
125
377
  return char === "\u3000" ? " " : char;
126
378
  }
127
379
 
128
- // src/controllers/input_mask_controller.ts
380
+ var ListenerSet = class {
381
+ #abort = new AbortController();
382
+ add(target, type, handler, options) {
383
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
384
+ }
385
+ dispose() {
386
+ this.#abort.abort();
387
+ this.#abort = new AbortController();
388
+ }
389
+ };
390
+
391
+ var MicrotaskCoalescer = class {
392
+ #run;
393
+ #queued = false;
394
+ #active = false;
395
+ #generation = 0;
396
+ constructor(run) {
397
+ this.#run = run;
398
+ }
399
+ activate() {
400
+ this.#active = true;
401
+ }
402
+ cancel() {
403
+ this.#active = false;
404
+ this.#queued = false;
405
+ this.#generation += 1;
406
+ }
407
+ get pending() {
408
+ return this.#queued;
409
+ }
410
+ schedule() {
411
+ if (!this.#active || this.#queued) return;
412
+ this.#queued = true;
413
+ const generation = this.#generation;
414
+ queueMicrotask(() => {
415
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
416
+ this.#queued = false;
417
+ this.#run();
418
+ });
419
+ }
420
+ };
421
+
422
+ var MorphRenderWatcher = class {
423
+ #listeners = new ListenerSet();
424
+ #pass;
425
+ #element = null;
426
+ #onMorph = () => this.#pass.schedule();
427
+ constructor(run) {
428
+ this.#pass = new MicrotaskCoalescer(run);
429
+ }
430
+ observe(element) {
431
+ if (this.#element === element) return;
432
+ this.disconnect();
433
+ this.#element = element;
434
+ this.#pass.activate();
435
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
436
+ }
437
+ schedule() {
438
+ this.#pass.schedule();
439
+ }
440
+ get pending() {
441
+ return this.#pass.pending;
442
+ }
443
+ disconnect() {
444
+ this.#listeners.dispose();
445
+ this.#pass.cancel();
446
+ this.#element = null;
447
+ }
448
+ };
449
+
450
+ var TimerRegistry = class {
451
+ ids = /* @__PURE__ */ new Set();
452
+ clear(id) {
453
+ if (this.ids.delete(id)) {
454
+ this.cancel(id);
455
+ }
456
+ }
457
+ clearAll() {
458
+ for (const id of this.ids) {
459
+ this.cancel(id);
460
+ }
461
+ this.ids.clear();
462
+ }
463
+ get size() {
464
+ return this.ids.size;
465
+ }
466
+ };
467
+ var SafeTimeout = class extends TimerRegistry {
468
+ set(callback, delay) {
469
+ const id = this.schedule(() => {
470
+ this.ids.delete(id);
471
+ callback();
472
+ }, delay);
473
+ this.ids.add(id);
474
+ return id;
475
+ }
476
+ schedule(callback, delay) {
477
+ return window.setTimeout(callback, delay);
478
+ }
479
+ cancel(id) {
480
+ window.clearTimeout(id);
481
+ }
482
+ };
483
+
484
+ function prepareInputMaskForm(form) {
485
+ prepareFormDataProjection(form);
486
+ }
129
487
  var DEFAULT_TOKENS = {
130
488
  "9": "\\d",
131
489
  a: "[A-Za-z]",
132
490
  "*": "[A-Za-z0-9]"
133
491
  };
134
492
  var UNMASK_ATTR = "unmask";
493
+ var rawWriters = sharedRegistry(
494
+ "stimeo-ui.input-mask-raw-writer.registry.v1",
495
+ () => /* @__PURE__ */ new WeakMap()
496
+ );
135
497
  function acceptedForm(regex, char) {
136
498
  if (regex.test(char)) return char;
137
499
  const half = halfWidthChar(char);
@@ -208,7 +570,6 @@ function tokenCharOffset(tokenFlags, n) {
208
570
  return -1;
209
571
  }
210
572
  var InputMaskController = class extends Controller {
211
- /** The attribute above, in the namespace this controller is registered under. */
212
573
  get #unmaskAttribute() {
213
574
  return `data-${this.identifier}-${UNMASK_ATTR}`;
214
575
  }
@@ -219,19 +580,16 @@ var InputMaskController = class extends Controller {
219
580
  };
220
581
  static actions = ["format"];
221
582
  static events = ["change", "reconcile"];
222
- /** The value this controller last committed, and the baseline both events compare. */
223
583
  #lastValue = null;
224
584
  #started = false;
225
- /** Validated token map; the hot path never parses the `tokens` declaration. */
585
+ #rawSink = null;
586
+ #rawValue = "";
587
+ #rawSourceForm = null;
588
+ #submission = new FormDataProjection();
226
589
  #tokens = compileTokens("");
227
- /** The field's text when the running composition began. */
228
590
  #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
- */
591
+ #formatted = null;
592
+ #shown = { result: null, empty: true };
235
593
  #composition = new CompositionTracker({
236
594
  onStart: () => {
237
595
  this.#textBeforeComposition = this.element.value;
@@ -239,9 +597,11 @@ var InputMaskController = class extends Controller {
239
597
  onEnd: () => {
240
598
  const committedNothing = this.element.value === this.#textBeforeComposition;
241
599
  this.#reformat(committedNothing ? "reconcile" : "edit");
600
+ },
601
+ onAbandon: () => {
602
+ if (this.#formatted !== this.#declared) this.#reformat("reconcile");
242
603
  }
243
604
  });
244
- /** Re-parses the declaration and re-formats under the tokens it now selects. */
245
605
  tokensValueChanged() {
246
606
  this.#tokens = compileTokens(this.tokensValue);
247
607
  if (this.#started) this.#reformat("reconcile");
@@ -249,41 +609,55 @@ var InputMaskController = class extends Controller {
249
609
  patternValueChanged() {
250
610
  if (this.#started) this.#reformat("reconcile");
251
611
  }
252
- /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */
253
612
  unmaskToHiddenValueChanged() {
254
613
  if (!this.#started) return;
255
- if (!this.unmaskToHiddenValue) {
256
- const sink = this.#resolveSink();
257
- if (sink) sink.value = "";
258
- }
614
+ if (!this.unmaskToHiddenValue) this.#withdraw();
259
615
  this.#reformat("reconcile");
260
616
  }
617
+ #withdraw() {
618
+ if (this.#retractRaw() && this.#composition.isComposing()) this.#formatted = null;
619
+ }
620
+ #morph = new MorphRenderWatcher(() => this.#reformat("reconcile"));
621
+ #gate = new DetachGate();
622
+ #adoption = new SafeTimeout();
261
623
  connect() {
624
+ this.#gate.cancel();
625
+ this.#morph.observe(this.element);
262
626
  this.#started = true;
263
627
  this.#composition.observe(this.element);
628
+ if (!this.unmaskToHiddenValue) this.#withdraw();
629
+ if (this.#composition.isComposing()) {
630
+ this.#reformat("reconcile");
631
+ return;
632
+ }
264
633
  this.#lastValue = this.element.value;
265
634
  this.#reformat("reconcile");
635
+ if (this.unmaskToHiddenValue && this.#rawSink === null) {
636
+ this.#adoption.set(() => this.#adoptSink(), 0);
637
+ }
266
638
  }
267
639
  disconnect() {
640
+ this.#morph.disconnect();
268
641
  this.#started = false;
269
642
  this.#composition.disconnect();
643
+ this.#adoption.clearAll();
644
+ this.#gate.disconnected(this, () => this.#retractRaw());
270
645
  }
271
- /** Formats the field on input, preserving the caret. Bound via `data-action`. */
272
646
  format(event) {
273
647
  if (this.#composition.isComposing(event)) return;
274
648
  this.#reformat("edit", deletionOf(event));
275
649
  }
276
- /**
277
- * Core reformat: mask the current value, restore the caret, sync the sink and
278
- * the state hooks, and report a moved value under the event `cause` selects.
279
- *
280
- * @stimeoRenderRoot
281
- */
282
650
  #reformat(cause, deletion = null) {
283
- if (this.#composition.isComposing()) return;
651
+ if (this.#composition.isComposing()) {
652
+ this.#showAgain();
653
+ return;
654
+ }
655
+ this.#formatted = this.#declared;
284
656
  const input = this.element;
285
657
  const raw = input.value;
286
658
  if (this.patternValue === "") {
659
+ this.#retractRaw();
660
+ this.#shown = { result: null, empty: raw.length === 0 };
287
661
  this.#flag("data-mask-complete", false);
288
662
  this.#flag("data-mask-empty", raw.length === 0);
289
663
  this.#lastValue = raw;
@@ -309,8 +683,10 @@ var InputMaskController = class extends Controller {
309
683
  this.#lastValue = raw;
310
684
  return;
311
685
  }
312
- input.value = result.masked;
313
- this.#restoreCaret(result.tokenFlags, anchor);
686
+ if (input.value !== result.masked) {
687
+ input.value = result.masked;
688
+ this.#restoreCaret(result.tokenFlags, anchor);
689
+ }
314
690
  this.#publish(result, result.masked);
315
691
  if (result.masked === this.#lastValue) return;
316
692
  this.#lastValue = result.masked;
@@ -318,24 +694,59 @@ var InputMaskController = class extends Controller {
318
694
  if (cause === "edit") this.dispatch("change", { detail });
319
695
  else this.dispatch("reconcile", { detail });
320
696
  }
321
- /** Syncs the raw-value sink and the state hooks for the `shown` field text. */
697
+ get #declared() {
698
+ return JSON.stringify([this.patternValue, this.tokensValue, this.unmaskToHiddenValue]);
699
+ }
322
700
  #publish(result, shown) {
701
+ this.#shown = { result, empty: shown.length === 0 };
323
702
  if (this.unmaskToHiddenValue) {
324
703
  const sink = this.#resolveSink();
325
- if (sink) sink.value = result.unmasked;
704
+ if (sink !== this.#rawSink) this.#retractRaw();
705
+ if (sink) {
706
+ sink.value = result.unmasked;
707
+ rawWriters.set(sink, this);
708
+ this.#rawSink = sink;
709
+ this.#rawValue = result.unmasked;
710
+ this.#rawSourceForm = this.element.form;
711
+ this.#submission.hold(sink, (value) => this.#submittedValue(sink, value));
712
+ }
326
713
  }
327
714
  this.#flag("data-mask-complete", result.complete);
328
715
  this.#flag("data-mask-empty", shown.length === 0);
329
716
  }
330
- /**
331
- * How many token slots the text before the caret fills. Masking that prefix is
332
- * what makes a rejected character — or a literal that also matches a token —
333
- * count exactly as the rendering counts it, so the caret cannot drift.
334
- */
717
+ #showAgain() {
718
+ const shown = this.#shown;
719
+ const sink = this.#rawSink;
720
+ if (shown.result !== null && this.unmaskToHiddenValue && sink !== null && rawWriters.get(sink) === this && this.#resolveSink() === sink) {
721
+ sink.value = shown.result.unmasked;
722
+ }
723
+ this.#flag("data-mask-complete", shown.result?.complete ?? false);
724
+ this.#flag("data-mask-empty", shown.empty);
725
+ }
726
+ #retractRaw() {
727
+ const sink = this.#rawSink;
728
+ this.#rawSink = null;
729
+ this.#submission.release();
730
+ if (sink === null || rawWriters.get(sink) !== this) return false;
731
+ rawWriters.delete(sink);
732
+ if (sink.value === this.#rawValue) sink.value = "";
733
+ return true;
734
+ }
735
+ #submittedValue(sink, value) {
736
+ if (rawWriters.get(sink) !== this || sink.value !== this.#rawValue || value !== this.#rawValue)
737
+ return value;
738
+ const source = this.element;
739
+ const listed = (source.getAttribute("data-controller") ?? "").split(/\s+/).includes(this.identifier);
740
+ const associated = source.form === null ? this.#rawSourceForm === null : source.form === sink.form;
741
+ const eligible = source.isConnected && this.application.element.contains(source) && listed && this.unmaskToHiddenValue && associated;
742
+ return eligible ? value : "";
743
+ }
744
+ #adoptSink() {
745
+ if (this.#resolveSink() !== null) this.#reformat("reconcile");
746
+ }
335
747
  #significantBefore(prefix) {
336
748
  return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;
337
749
  }
338
- /** Places the caret after the `n`-th token char (skipping following literals). */
339
750
  #restoreCaret(tokenFlags, n) {
340
751
  let position;
341
752
  if (n <= 0) {
@@ -357,23 +768,6 @@ var InputMaskController = class extends Controller {
357
768
  } catch {
358
769
  }
359
770
  }
360
- /**
361
- * The hidden raw-value sink for this input, resolved so several masked inputs
362
- * can coexist in one form:
363
- *
364
- * 1. **Explicit pairing** — a sink whose attribute value names this input's
365
- * `id` (`data-stimeo--input-mask-unmask="zip"`).
366
- * 2. **Nearest container** — otherwise, walking up from the input, the first
367
- * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
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.
370
- *
371
- * Both steps only ever consider the associated form's own controls, so a sink
372
- * belonging to a different form (or to none) is never written, while one the
373
- * `form` attribute associates from elsewhere in the document still resolves —
374
- * for either the input or the sink, neither of which needs to contain or be
375
- * contained by the form. A form-less input reads the document instead.
376
- */
377
771
  #resolveSink() {
378
772
  const candidates = this.#sinkCandidates();
379
773
  const id = this.element.id;
@@ -389,12 +783,6 @@ var InputMaskController = class extends Controller {
389
783
  }
390
784
  return null;
391
785
  }
392
- /**
393
- * Every sink the input's form owns, in document order — one collection read per
394
- * resolution, so the cost tracks the form's own controls and not the depth of
395
- * the markup around it. `form.elements` lists controls the `form` attribute
396
- * associates from anywhere in the document, and lists nothing another form owns.
397
- */
398
786
  #sinkCandidates() {
399
787
  const form = this.element.form;
400
788
  if (form === null) {
@@ -406,13 +794,11 @@ var InputMaskController = class extends Controller {
406
794
  (element) => element instanceof HTMLInputElement && element.hasAttribute(this.#unmaskAttribute)
407
795
  );
408
796
  }
409
- /** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
410
797
  #flag(name, on) {
411
798
  if (on) this.element.setAttribute(name, "true");
412
799
  else this.element.removeAttribute(name);
413
800
  }
414
801
  };
415
802
 
416
- export { InputMaskController, applyMask };
417
- //# sourceMappingURL=input_mask_controller.js.map
803
+ export { InputMaskController, applyMask, prepareInputMaskForm };
418
804
  //# sourceMappingURL=input_mask_controller.js.map