stimeo-ui 0.15.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -262,6 +262,10 @@ var FrameLoadingController = class extends Controller {
262
262
  this.#floor.cancel();
263
263
  if (!this.#loading) this.#begin();
264
264
  };
265
+ /**
266
+ * @stimeoRuntimeOnly `minDuration` holds the loading state at least this long for the one load
267
+ * that ended.
268
+ */
265
269
  #onEnd = () => {
266
270
  if (!this.#loading) return;
267
271
  this.#floor.schedule(this.minDurationValue, () => this.#finish());
@@ -363,7 +367,12 @@ var FrameLoadingController = class extends Controller {
363
367
  this.#revealedOverlay = this.overlayTarget;
364
368
  this.overlayTarget.hidden = false;
365
369
  }
366
- /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
370
+ /**
371
+ * Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat.
372
+ *
373
+ * @stimeoRuntimeOnly `announceText` words the one announcement of this load and `restoreFocus`
374
+ * decides whether focus leaves the frame it covers.
375
+ */
367
376
  #begin() {
368
377
  this.#loading = true;
369
378
  this.#floor.begin();
@@ -376,7 +385,12 @@ var FrameLoadingController = class extends Controller {
376
385
  this.dispatch("start", { detail: {} });
377
386
  announce(fillTemplate(this.announceTextValue, {}));
378
387
  }
379
- /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
388
+ /**
389
+ * Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus.
390
+ *
391
+ * @stimeoRuntimeOnly `announceReadyText` words the one announcement of this finished load and
392
+ * `restoreFocus` decides whether focus goes back.
393
+ */
380
394
  #finish() {
381
395
  this.#loading = false;
382
396
  this.#rewindHooks();
@@ -103,7 +103,20 @@ function prefersReducedMotion() {
103
103
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
104
104
  }
105
105
 
106
+ // src/utils/transient_hooks.ts
107
+ var TransientHooks = class {
108
+ #attributes;
109
+ constructor(options) {
110
+ this.#attributes = options.attributes;
111
+ }
112
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
113
+ reset(element) {
114
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
115
+ }
116
+ };
117
+
106
118
  // src/controllers/highlight_controller.ts
119
+ var TRANSIENT = new TransientHooks({ attributes: ["data-highlight"] });
107
120
  var hookOwners = /* @__PURE__ */ new WeakMap();
108
121
  var HighlightController = class extends Controller {
109
122
  static values = {
@@ -137,7 +150,7 @@ var HighlightController = class extends Controller {
137
150
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
138
151
  #clearArrivedHook(el) {
139
152
  if (hookOwners.get(el) === this) hookOwners.delete(el);
140
- el.removeAttribute("data-highlight");
153
+ TRANSIENT.reset(el);
141
154
  }
142
155
  /** Highlights every element child added by a childList mutation. */
143
156
  #onMutations(mutations) {
@@ -147,7 +160,11 @@ var HighlightController = class extends Controller {
147
160
  }
148
161
  }
149
162
  }
150
- /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
163
+ /**
164
+ * Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion).
165
+ *
166
+ * @stimeoRuntimeOnly `duration` is the length of the one emphasis this call arms.
167
+ */
151
168
  #highlight(el) {
152
169
  if (prefersReducedMotion()) return;
153
170
  this.#releasePending(el);
@@ -164,6 +164,16 @@ function isScrollable(element) {
164
164
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
165
165
  }
166
166
 
167
+ // src/utils/state_reason.ts
168
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
169
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
170
+ function stateReasonFor(event) {
171
+ if (!event) return "api";
172
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
173
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
174
+ return "user";
175
+ }
176
+
167
177
  // src/controllers/hover_card_controller.ts
168
178
  var HoverCardController = class extends Controller {
169
179
  static targets = ["trigger", "card"];
@@ -173,6 +183,7 @@ var HoverCardController = class extends Controller {
173
183
  closeOnScroll: { type: Boolean, default: false }
174
184
  };
175
185
  static actions = ["close", "open"];
186
+ static events = ["close", "open"];
176
187
  /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
177
188
  #timers = new SafeTimeout();
178
189
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
@@ -181,14 +192,18 @@ var HoverCardController = class extends Controller {
181
192
  #pendingClose = null;
182
193
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
183
194
  #stopScrollDismiss = null;
195
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
196
+ #reporting = false;
184
197
  /** Starts closed and discards any stale pending state from a prior connection. */
185
198
  connect() {
186
199
  this.#cancelOpen();
187
200
  this.#cancelClose();
188
- this.#conceal();
201
+ this.#conceal("api");
202
+ this.#reporting = true;
189
203
  }
190
204
  /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
191
205
  disconnect() {
206
+ this.#reporting = false;
192
207
  this.#cancelOpen();
193
208
  this.#cancelClose();
194
209
  this.#escapeLayer.deactivate();
@@ -196,16 +211,17 @@ var HoverCardController = class extends Controller {
196
211
  this.#stopScrollDismiss = null;
197
212
  }
198
213
  /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
199
- open() {
214
+ open(event) {
215
+ const reason = stateReasonFor(event);
200
216
  this.#cancelClose();
201
217
  if (this.#isOpen || this.#pendingOpen !== null) return;
202
218
  if (this.openDelayValue <= 0) {
203
- this.#reveal();
219
+ this.#reveal(reason);
204
220
  return;
205
221
  }
206
222
  this.#pendingOpen = this.#timers.set(() => {
207
223
  this.#pendingOpen = null;
208
- this.#reveal();
224
+ this.#reveal(reason);
209
225
  }, this.openDelayValue);
210
226
  }
211
227
  /**
@@ -214,28 +230,37 @@ var HoverCardController = class extends Controller {
214
230
  * (e.g. a link in the card) and, if so, aborts the close — covering keyboard
215
231
  * traversal that the pointer-only hoverable bridge cannot.
216
232
  */
217
- close() {
233
+ close(event) {
234
+ const reason = stateReasonFor(event);
218
235
  this.#cancelOpen();
219
236
  if (!this.#isOpen || this.#pendingClose !== null) return;
220
237
  this.#pendingClose = this.#timers.set(() => {
221
238
  this.#pendingClose = null;
222
239
  if (this.element.contains(document.activeElement)) return;
223
- this.#conceal();
240
+ this.#conceal(reason);
224
241
  }, this.closeDelayValue);
225
242
  }
226
- /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
227
- #reveal() {
243
+ /**
244
+ * Reveals the card, reflects state, reports a move, and joins the Escape stack.
245
+ *
246
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what
247
+ * is shown does not depend on it.
248
+ */
249
+ #reveal(reason) {
228
250
  if (!this.hasCardTarget) return;
251
+ const was = this.#isOpen;
229
252
  this.cardTarget.hidden = false;
230
253
  this.cardTarget.setAttribute("data-state", "open");
231
254
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
232
- this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
255
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
256
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss("escape") });
233
257
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
234
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
258
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss("scroll"));
235
259
  }
236
260
  }
237
- /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
238
- #conceal() {
261
+ /** Hides the card, reflects state, reports a move, and leaves the Escape stack. */
262
+ #conceal(reason) {
263
+ const was = this.#isOpen;
239
264
  this.#escapeLayer.deactivate();
240
265
  this.#stopScrollDismiss?.();
241
266
  this.#stopScrollDismiss = null;
@@ -244,12 +269,13 @@ var HoverCardController = class extends Controller {
244
269
  this.cardTarget.setAttribute("data-state", "closed");
245
270
  }
246
271
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
272
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
247
273
  }
248
274
  /** Cancels pending timers and conceals immediately (shared Escape path). */
249
- #dismiss() {
275
+ #dismiss(reason) {
250
276
  this.#cancelOpen();
251
277
  this.#cancelClose();
252
- this.#conceal();
278
+ this.#conceal(reason);
253
279
  }
254
280
  /** Cancels any pending open timer. */
255
281
  #cancelOpen() {
@@ -55,6 +55,47 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/state_regions.ts
59
+ var StateRegions = class {
60
+ #whenTrue;
61
+ #whenFalse;
62
+ #taken = /* @__PURE__ */ new WeakSet();
63
+ constructor(options) {
64
+ this.#whenTrue = options.whenTrue;
65
+ this.#whenFalse = options.whenFalse ?? null;
66
+ }
67
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
68
+ reflect(host, isTrue) {
69
+ const shown = this.#inside(host, this.#whenTrue());
70
+ if (!this.#whenFalse) {
71
+ for (const region of shown) this.#write(region, !isTrue);
72
+ return;
73
+ }
74
+ const hiddenSide = this.#inside(host, this.#whenFalse());
75
+ if (shown.length === 0 || hiddenSide.length === 0) {
76
+ for (const region of shown) this.#give(region);
77
+ for (const region of hiddenSide) this.#give(region);
78
+ return;
79
+ }
80
+ for (const region of shown) this.#write(region, !isTrue);
81
+ for (const region of hiddenSide) this.#write(region, isTrue);
82
+ }
83
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
84
+ #write(region, hidden) {
85
+ if (hidden) this.#taken.add(region);
86
+ else this.#taken.delete(region);
87
+ if (region.hidden !== hidden) region.hidden = hidden;
88
+ }
89
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
90
+ #give(region) {
91
+ if (this.#taken.delete(region)) region.hidden = false;
92
+ }
93
+ /** The declared regions that sit within `host`, which may be the host itself. */
94
+ #inside(host, regions) {
95
+ return regions.filter((region) => host.contains(region));
96
+ }
97
+ };
98
+
58
99
  // src/utils/string_list.ts
59
100
  function parseStringList(raw, fallback = []) {
60
101
  const text = raw.trim();
@@ -87,7 +128,12 @@ var IdleController = class extends Controller {
87
128
  // runs, so one malformed attribute would stop the detector connecting.
88
129
  events: { type: String, default: "" }
89
130
  };
131
+ static targets = ["prompt", "idle"];
90
132
  static events = ["prompt", "idle", "active"];
133
+ /** The regions of the warning window, revealed alongside `data-prompt`. */
134
+ #promptRegions = new StateRegions({ whenTrue: () => this.promptTargets });
135
+ /** The regions of the elapsed timeout, revealed alongside `data-idle`. */
136
+ #idleRegions = new StateRegions({ whenTrue: () => this.idleTargets });
91
137
  #timeouts = new SafeTimeout();
92
138
  #idle = false;
93
139
  #prompted = false;
@@ -104,7 +150,7 @@ var IdleController = class extends Controller {
104
150
  if (this.#idle || this.#prompted) {
105
151
  this.#idle = false;
106
152
  this.#prompted = false;
107
- this.element.removeAttribute("data-idle");
153
+ this.#reflect();
108
154
  this.dispatch("active", { detail: {} });
109
155
  this.#arm();
110
156
  }
@@ -115,7 +161,7 @@ var IdleController = class extends Controller {
115
161
  connect() {
116
162
  this.#idle = false;
117
163
  this.#prompted = false;
118
- this.element.removeAttribute("data-idle");
164
+ this.#reflect();
119
165
  this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
120
166
  for (const type of this.#boundEvents) {
121
167
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
@@ -131,7 +177,11 @@ var IdleController = class extends Controller {
131
177
  document.removeEventListener("visibilitychange", this.#onVisibility);
132
178
  this.#timeouts.clearAll();
133
179
  }
134
- /** Schedules the prompt and idle checks from the current activity baseline. */
180
+ /**
181
+ * Schedules the prompt and idle checks from the current activity baseline.
182
+ *
183
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` time the checks this call arms.
184
+ */
135
185
  #arm() {
136
186
  this.#timeouts.clearAll();
137
187
  this.#lastActivity = Date.now();
@@ -145,6 +195,9 @@ var IdleController = class extends Controller {
145
195
  * Idle-timer callback: go idle only if there has genuinely been no activity for
146
196
  * `timeout`; otherwise reschedule for the remaining time. This lets activity events
147
197
  * stay O(1) (a timestamp write) while the deadline still tracks the last activity.
198
+ *
199
+ * @stimeoRuntimeOnly `timeout` sets the deadline this one check compares against; the phase it
200
+ * shows follows the elapsed time.
148
201
  */
149
202
  #checkIdle() {
150
203
  const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);
@@ -154,19 +207,51 @@ var IdleController = class extends Controller {
154
207
  }
155
208
  this.#idle = true;
156
209
  this.#prompted = false;
157
- this.element.setAttribute("data-idle", "true");
210
+ this.#reflect();
158
211
  this.dispatch("idle", { detail: {} });
159
212
  }
160
- /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */
213
+ /**
214
+ * Prompt-timer callback: warn at `promptBefore` before the idle deadline. A window
215
+ * narrowed while the cycle runs can push the warning onto the deadline or past it,
216
+ * and the phase it belongs to is the one before idle, so a lapsed cycle keeps the
217
+ * phase it reached. The next window opens with the cycle that activity arms.
218
+ *
219
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` set the deadlines this one check compares
220
+ * against; the phase it shows follows the elapsed time.
221
+ */
161
222
  #checkPrompt() {
223
+ if (this.#idle) return;
162
224
  const remaining = this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);
163
225
  if (remaining > 0) {
164
226
  this.#timeouts.set(() => this.#checkPrompt(), remaining);
165
227
  return;
166
228
  }
167
229
  this.#prompted = true;
230
+ this.#reflect();
168
231
  this.dispatch("prompt", { detail: { remaining: this.promptBeforeValue } });
169
232
  }
233
+ /** Applies the current phase to a warning region inserted or replaced at runtime. */
234
+ promptTargetConnected() {
235
+ this.#promptRegions.reflect(this.element, this.#prompted);
236
+ }
237
+ /** Applies the current phase to an idle region inserted or replaced at runtime. */
238
+ idleTargetConnected() {
239
+ this.#idleRegions.reflect(this.element, this.#idle);
240
+ }
241
+ /**
242
+ * Writes the phase to the element and to the declared regions. Both hooks and both
243
+ * regions are a pure function of the two flags, so every place that moves a flag ends
244
+ * here and the markup can never disagree with the phase the detector is in.
245
+ */
246
+ #reflect() {
247
+ const { element } = this;
248
+ if (this.#prompted) element.setAttribute("data-prompt", "true");
249
+ else element.removeAttribute("data-prompt");
250
+ if (this.#idle) element.setAttribute("data-idle", "true");
251
+ else element.removeAttribute("data-idle");
252
+ this.#promptRegions.reflect(element, this.#prompted);
253
+ this.#idleRegions.reflect(element, this.#idle);
254
+ }
170
255
  };
171
256
 
172
257
  export { IdleController };
@@ -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. */
@@ -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");
@@ -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) {