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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -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 +81 -12
  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 +85 -17
  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 +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  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 +46 -13
  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 +432 -71
  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 +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  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/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -162,6 +162,16 @@ var SafeTimeout = class extends TimerRegistry {
162
162
  }
163
163
  };
164
164
 
165
+ // src/utils/state_reason.ts
166
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
167
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
168
+ function stateReasonFor(event) {
169
+ if (!event) return "api";
170
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
171
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
172
+ return "user";
173
+ }
174
+
165
175
  // src/controllers/navigation_menu_controller.ts
166
176
  var NavigationMenuController = class extends Controller {
167
177
  static targets = ["trigger", "panel", "hoverArea"];
@@ -170,6 +180,7 @@ var NavigationMenuController = class extends Controller {
170
180
  hoverDelay: { type: Number, default: 150 }
171
181
  };
172
182
  static actions = ["onTriggerKeydown", "toggle"];
183
+ static events = ["close", "open"];
173
184
  /** Open/close delay timers for hover mode; cleared together on disconnect. */
174
185
  #hoverTimers = new SafeTimeout();
175
186
  /**
@@ -190,19 +201,23 @@ var NavigationMenuController = class extends Controller {
190
201
  * rebuild would re-wire hover listeners onto an already-disconnected nav.
191
202
  */
192
203
  #connected = false;
204
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
205
+ #reporting = false;
193
206
  /** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
194
207
  #escapeLayer = new EscapeLayer();
195
208
  /** Establishes the closed baseline and the dismissal listeners. */
196
209
  connect() {
197
210
  this.#connected = true;
198
- this.#closeAll();
211
+ this.#closeAll("api");
199
212
  document.addEventListener("click", this.#onOutsideClick, true);
200
213
  this.element.addEventListener("focusout", this.#onFocusOut);
201
214
  if (this.openOnHoverValue) this.#addHoverListeners();
215
+ this.#reporting = true;
202
216
  }
203
217
  /** Removes every listener, pending hover timer, and stack membership taken while connected. */
204
218
  disconnect() {
205
219
  this.#connected = false;
220
+ this.#reporting = false;
206
221
  this.#escapeLayer.deactivate();
207
222
  document.removeEventListener("click", this.#onOutsideClick, true);
208
223
  this.element.removeEventListener("focusout", this.#onFocusOut);
@@ -259,13 +274,14 @@ var NavigationMenuController = class extends Controller {
259
274
  */
260
275
  toggle(event) {
261
276
  const trigger = event.currentTarget;
277
+ const reason = stateReasonFor(event);
262
278
  this.#hoverTimers.clearAll();
263
279
  if (!this.#isExpanded(trigger)) {
264
- this.#openPanel(trigger);
280
+ this.#openPanel(trigger, reason);
265
281
  return;
266
282
  }
267
283
  if (this.openOnHoverValue && this.#hoveredTrigger === trigger) return;
268
- this.#closePanel(trigger);
284
+ this.#closePanel(trigger, reason);
269
285
  }
270
286
  /**
271
287
  * `ArrowLeft`/`ArrowRight` move focus between triggers (keeping Tab order).
@@ -297,24 +313,27 @@ var NavigationMenuController = class extends Controller {
297
313
  * already-open panel only re-asserts the Escape layer: the close/open
298
314
  * round-trip would rewrite `aria-expanded` and `hidden` for no state change.
299
315
  */
300
- #openPanel(trigger) {
316
+ #openPanel(trigger, reason) {
301
317
  if (trigger.getAttribute("aria-disabled") === "true") return;
302
318
  if (this.#isExpanded(trigger)) {
303
319
  this.#syncEscapeLayer();
304
320
  return;
305
321
  }
306
- this.#closeAll();
322
+ this.#closeAll(reason);
307
323
  const panel = this.#panelFor(trigger);
308
324
  if (!panel) return;
309
325
  panel.hidden = false;
310
326
  trigger.setAttribute("aria-expanded", "true");
327
+ this.#report("open", trigger, panel, reason);
311
328
  this.#syncEscapeLayer();
312
329
  }
313
- /** Closes `trigger`'s panel and reflects the collapsed state. */
314
- #closePanel(trigger) {
330
+ /** Closes `trigger`'s panel, reflects the collapsed state, and reports a move. */
331
+ #closePanel(trigger, reason) {
332
+ const was = this.#isExpanded(trigger);
315
333
  const panel = this.#panelFor(trigger);
316
334
  if (panel) panel.hidden = true;
317
335
  trigger.setAttribute("aria-expanded", "false");
336
+ if (was && panel) this.#report("close", trigger, panel, reason);
318
337
  this.#syncEscapeLayer();
319
338
  }
320
339
  /**
@@ -322,8 +341,16 @@ var NavigationMenuController = class extends Controller {
322
341
  * triggers, so a trigger whose panel was hidden by other means cannot keep
323
342
  * advertising an open panel.
324
343
  */
325
- #closeAll() {
326
- for (const trigger of this.triggerTargets) this.#closePanel(trigger);
344
+ #closeAll(reason) {
345
+ for (const trigger of this.triggerTargets) this.#closePanel(trigger, reason);
346
+ }
347
+ /** Names the panel that moved, so a subscriber does not have to re-derive it. */
348
+ #report(name, trigger, panel, reason) {
349
+ if (!this.#reporting) return;
350
+ const index = this.triggerTargets.indexOf(trigger);
351
+ const detail = { reason, index, panel };
352
+ if (name === "open") this.dispatch("open", { detail, cancelable: false });
353
+ else this.dispatch("close", { detail, cancelable: false });
327
354
  }
328
355
  /**
329
356
  * Whether a trigger can receive arrow-key focus: not `hidden`, not a natively
@@ -365,12 +392,12 @@ var NavigationMenuController = class extends Controller {
365
392
  const open = this.#openTrigger;
366
393
  if (!open) return;
367
394
  this.#hoverTimers.clearAll();
368
- this.#closePanel(open);
395
+ this.#closePanel(open, "escape");
369
396
  open.focus();
370
397
  }
371
398
  /** Closes panels when a click lands outside the nav element. */
372
399
  #onOutsideClick = (event) => {
373
- if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
400
+ if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll("outside");
374
401
  };
375
402
  /**
376
403
  * Closes (without restoring focus) when focus leaves the nav for a known
@@ -383,9 +410,13 @@ var NavigationMenuController = class extends Controller {
383
410
  #onFocusOut = (event) => {
384
411
  const next = event.relatedTarget;
385
412
  if (!(next instanceof Node) || this.element.contains(next)) return;
386
- this.#closeAll();
413
+ this.#closeAll("focus");
387
414
  };
388
- /** Opens a trigger's panel after the hover delay (hover mode). */
415
+ /**
416
+ * Opens a trigger's panel after the hover delay (hover mode).
417
+ *
418
+ * @stimeoRuntimeOnly `hoverDelay` is the delay of the one hover timer this entry arms.
419
+ */
389
420
  #onPointerEnter = (event) => {
390
421
  const trigger = this.#triggerForHover(event.currentTarget);
391
422
  if (!trigger) return;
@@ -398,6 +429,8 @@ var NavigationMenuController = class extends Controller {
398
429
  * pointer moved directly into another part of the hover region (an adjacent
399
430
  * trigger, panel, or hoverArea): crossing a shared edge must not schedule a
400
431
  * spurious close, e.g. a hoverArea whose panel sits outside it as a sibling.
432
+ *
433
+ * @stimeoRuntimeOnly `hoverDelay` is the delay of the one hover timer this exit arms.
401
434
  */
402
435
  #onPointerLeave = (event) => {
403
436
  const next = event instanceof MouseEvent ? event.relatedTarget : null;
@@ -416,7 +449,7 @@ var NavigationMenuController = class extends Controller {
416
449
  if (trigger.getAttribute("aria-disabled") === "true") return;
417
450
  const open = this.#openTrigger;
418
451
  if (open && open !== trigger && this.#holdsFocus(this.#panelFor(open))) open.focus();
419
- this.#openPanel(trigger);
452
+ this.#openPanel(trigger, "pointer");
420
453
  }
421
454
  /**
422
455
  * Hover-driven close: skipped while focus sits inside the panel it would hide,
@@ -427,7 +460,7 @@ var NavigationMenuController = class extends Controller {
427
460
  #closeFromHover() {
428
461
  const open = this.#openTrigger;
429
462
  if (open && this.#holdsFocus(this.#panelFor(open))) return;
430
- this.#closeAll();
463
+ this.#closeAll("pointer");
431
464
  }
432
465
  /** Whether `panel` exists and currently contains the focused element. */
433
466
  #holdsFocus(panel) {
@@ -228,12 +228,28 @@ var TabindexLoan = class {
228
228
  }
229
229
  };
230
230
 
231
+ // src/utils/target_selector.ts
232
+ function targetSelector(identifier, name) {
233
+ return `[data-${identifier}-target~="${name}"]`;
234
+ }
235
+
231
236
  // src/controllers/nested_form_controller.ts
232
- var ROOT_SELECTOR = '[data-controller~="stimeo--nested-form"]';
233
- var REMOVE_SELECTOR = '[data-stimeo--nested-form-target="remove"]';
234
- var DESTROY_FLAG_SELECTOR = '[data-stimeo--nested-form-target="destroyFlag"]';
237
+ var REMOVE_PART = "remove";
238
+ var DESTROY_FLAG_PART = "destroyFlag";
235
239
  var DESTROYED_VALUES = /* @__PURE__ */ new Set(["1", "true"]);
236
240
  var NestedFormController = class extends Controller {
241
+ /** Selects any root of this controller; the nearest one owns an element. */
242
+ get #rootSelector() {
243
+ return `[data-controller~="${this.identifier}"]`;
244
+ }
245
+ /** Selects per-row remove buttons, in the namespace this controller is registered under. */
246
+ get #removeSelector() {
247
+ return targetSelector(this.identifier, REMOVE_PART);
248
+ }
249
+ /** Selects the hidden `_destroy` input a persisted row carries. */
250
+ get #destroyFlagSelector() {
251
+ return targetSelector(this.identifier, DESTROY_FLAG_PART);
252
+ }
237
253
  static targets = ["list", "template", "add", "remove", "destroyFlag"];
238
254
  static values = {
239
255
  min: { type: Number, default: 0 },
@@ -265,7 +281,7 @@ var NestedFormController = class extends Controller {
265
281
  */
266
282
  #onClick = (event) => {
267
283
  const target = event.target;
268
- const button = target?.closest(REMOVE_SELECTOR);
284
+ const button = target?.closest(this.#removeSelector);
269
285
  if (!button || this.#ownerOf(button) !== this.element) return;
270
286
  const row = this.#rowContaining(button);
271
287
  if (row) this.#removeRow(row);
@@ -362,6 +378,9 @@ var NestedFormController = class extends Controller {
362
378
  return;
363
379
  }
364
380
  const row = added[0];
381
+ for (const node of Array.from(list.childNodes).slice(beforeNodes)) {
382
+ if (node !== row) node.remove();
383
+ }
365
384
  this.#refresh();
366
385
  firstTabStop(row)?.focus();
367
386
  this.dispatch("add", { detail: { index, element: row } });
@@ -371,6 +390,8 @@ var NestedFormController = class extends Controller {
371
390
  * Removes a row: a persisted row (one carrying its own `destroyFlag`) has the
372
391
  * flag set to `1` and is hidden so Rails destroys it on submit; an unsaved row
373
392
  * is dropped from the DOM. Returns focus to a surviving row. No-ops at `min`.
393
+ *
394
+ * @stimeoRuntimeOnly `min` decides whether this one removal is allowed.
374
395
  */
375
396
  #removeRow(row) {
376
397
  const rows = this.#effectiveRows;
@@ -426,7 +447,11 @@ var NestedFormController = class extends Controller {
426
447
  this.#tabindex.lend(this.element);
427
448
  this.element.focus();
428
449
  }
429
- /** Recomputes the count and min/max hooks from the DOM and records them as published. */
450
+ /**
451
+ * Recomputes the count and min/max hooks from the DOM and records them as published.
452
+ *
453
+ * @stimeoRenderRoot
454
+ */
430
455
  #refresh() {
431
456
  if (!this.hasListTarget) return;
432
457
  const count = this.#effectiveRows.length;
@@ -455,7 +480,11 @@ var NestedFormController = class extends Controller {
455
480
  const moved = previous.count !== current.count || previous.atMin !== current.atMin || previous.atMax !== current.atMax;
456
481
  if (moved) this.dispatch("reconcile", { detail: { ...current } });
457
482
  }
458
- /** Bridges the count change to the shared announcer when configured. */
483
+ /**
484
+ * Bridges the count change to the shared announcer when configured.
485
+ *
486
+ * @stimeoRuntimeOnly `announce` and `countMessage` word the one announcement of this change.
487
+ */
459
488
  #announce() {
460
489
  if (!this.announceValue || this.countMessageValue === "") return;
461
490
  announce(fillTemplate(this.countMessageValue, { count: this.#effectiveRows.length }));
@@ -514,14 +543,14 @@ var NestedFormController = class extends Controller {
514
543
  }
515
544
  /** The row's own destroy flag, skipping flags owned by a nested inner form. */
516
545
  #destroyFlagOf(row) {
517
- for (const flag of row.querySelectorAll(DESTROY_FLAG_SELECTOR)) {
546
+ for (const flag of row.querySelectorAll(this.#destroyFlagSelector)) {
518
547
  if (this.#ownerOf(flag) === this.element) return flag;
519
548
  }
520
549
  return null;
521
550
  }
522
551
  /** The nearest nested-form root that owns `el`. */
523
552
  #ownerOf(el) {
524
- return el.closest(ROOT_SELECTOR);
553
+ return el.closest(this.#rootSelector);
525
554
  }
526
555
  /** The nearest ancestor of `el` that is a direct child of the list, else null. */
527
556
  #rowContaining(el) {
@@ -105,6 +105,9 @@ var NetworkStatusController = class extends Controller {
105
105
  *
106
106
  * The event goes out last, so a listener reading `data-state` or a banner's
107
107
  * visibility sees the state the transition landed on rather than the previous one.
108
+ *
109
+ * @stimeoRuntimeOnly The texts word the one announcement of this transition; the state it shows
110
+ * comes from the browser.
108
111
  */
109
112
  #update(online) {
110
113
  if (online === this.#online) return;
@@ -126,7 +129,12 @@ var NetworkStatusController = class extends Controller {
126
129
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
127
130
  if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
128
131
  }
129
- /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
132
+ /**
133
+ * Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`.
134
+ *
135
+ * @stimeoRuntimeOnly `onlineAutoHide` is the delay of the one timer that hides the banner this
136
+ * transition shows.
137
+ */
130
138
  #showOnline() {
131
139
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
132
140
  if (!this.hasOnlineTarget) return;
@@ -79,11 +79,19 @@ var BeforeCacheReset = class _BeforeCacheReset {
79
79
  };
80
80
 
81
81
  // src/utils/composition_tracker.ts
82
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
83
+ "insertCompositionText",
84
+ "insertFromComposition",
85
+ "deleteCompositionText",
86
+ "deleteByComposition"
87
+ ]);
82
88
  var CompositionTracker = class {
83
89
  #observedTargets = /* @__PURE__ */ new Set();
84
90
  #activeTargets = /* @__PURE__ */ new Set();
85
91
  #onStart;
86
92
  #onEnd;
93
+ /** The field whose confirming `input` is still owed, while the window is open. */
94
+ #confirmedTarget = null;
87
95
  constructor(options = {}) {
88
96
  this.#onStart = options.onStart;
89
97
  this.#onEnd = options.onEnd;
@@ -93,6 +101,7 @@ var CompositionTracker = class {
93
101
  if (this.#observedTargets.has(target)) return;
94
102
  target.addEventListener("compositionstart", this.#handleStart);
95
103
  target.addEventListener("compositionend", this.#handleEnd);
104
+ target.addEventListener("keydown", this.#handleKeydown);
96
105
  this.#observedTargets.add(target);
97
106
  }
98
107
  /** Stops tracking one target and clears any active composition it owned. */
@@ -100,31 +109,74 @@ var CompositionTracker = class {
100
109
  if (!this.#observedTargets.delete(target)) return;
101
110
  target.removeEventListener("compositionstart", this.#handleStart);
102
111
  target.removeEventListener("compositionend", this.#handleEnd);
112
+ target.removeEventListener("keydown", this.#handleKeydown);
103
113
  this.#activeTargets.delete(target);
114
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
104
115
  }
105
116
  /** Releases every listener and clears state so reconnect starts cleanly. */
106
117
  disconnect() {
107
118
  for (const target of this.#observedTargets) {
108
119
  target.removeEventListener("compositionstart", this.#handleStart);
109
120
  target.removeEventListener("compositionend", this.#handleEnd);
121
+ target.removeEventListener("keydown", this.#handleKeydown);
110
122
  }
111
123
  this.#observedTargets.clear();
112
124
  this.#activeTargets.clear();
125
+ this.#confirmedTarget = null;
113
126
  }
114
127
  /** True when lifecycle tracking or the current event reports composition. */
115
128
  isComposing(event) {
116
129
  return this.#activeTargets.size > 0 || event?.isComposing === true;
117
130
  }
131
+ /**
132
+ * Whether `event` is the `input` echoing the composition just confirmed.
133
+ *
134
+ * Asking closes the window either way, so one confirmation is folded at most
135
+ * once and a consumer asks once per `input`.
136
+ */
137
+ consumesConfirmedInput(event) {
138
+ const confirmed = this.#confirmedTarget;
139
+ this.#confirmedTarget = null;
140
+ if (confirmed === null || confirmed !== event.target) return false;
141
+ const inputType = event.inputType;
142
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
143
+ }
118
144
  #handleStart = (event) => {
145
+ this.#confirmedTarget = null;
119
146
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
120
147
  this.#onStart?.(event);
121
148
  };
122
149
  #handleEnd = (event) => {
123
150
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
151
+ this.#confirmedTarget = event.target;
124
152
  this.#onEnd?.(event);
125
153
  };
154
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
155
+ #handleKeydown = () => {
156
+ this.#confirmedTarget = null;
157
+ };
126
158
  };
127
159
 
160
+ // src/utils/field_mirror.ts
161
+ function commitEdit(control) {
162
+ control.dispatchEvent(new Event("input", { bubbles: true }));
163
+ control.dispatchEvent(new Event("change", { bubbles: true }));
164
+ }
165
+
166
+ // src/utils/half_width.ts
167
+ var FULL_WIDTH_SHIFT = 65248;
168
+ function halfWidthChar(char) {
169
+ if (char >= "\uFF01" && char <= "\uFF5E") {
170
+ return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
171
+ }
172
+ return char === "\u3000" ? " " : char;
173
+ }
174
+ function toHalfWidth(text) {
175
+ let out = "";
176
+ for (const char of text) out += halfWidthChar(char);
177
+ return out;
178
+ }
179
+
128
180
  // src/utils/microtask_coalescer.ts
129
181
  var MicrotaskCoalescer = class {
130
182
  #run;
@@ -358,15 +410,34 @@ var NumberInputController = class _NumberInputController extends Controller {
358
410
  #globalGuards = null;
359
411
  /** Per-target guards allow an old button to be released without touching its replacement. */
360
412
  #buttonGuards = /* @__PURE__ */ new Map();
361
- /** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
362
- #composition = new CompositionTracker();
413
+ /**
414
+ * Tracks IME lifecycle on the current input, including confirming keys without a
415
+ * signal, and runs a reconciliation the composition held back once it ends.
416
+ */
417
+ #composition = new CompositionTracker({
418
+ onEnd: () => {
419
+ if (this.#reconcileHeld) this.#repaint.schedule();
420
+ }
421
+ });
422
+ /** A reconciliation that arrived while the input was composing. */
423
+ #reconcileHeld = false;
363
424
  /** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
364
425
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
365
426
  #ariaValueNow = new AttributeLease("aria-valuenow");
366
427
  #ariaValueMin = new AttributeLease("aria-valuemin");
367
428
  #ariaValueMax = new AttributeLease("aria-valuemax");
368
- /** Last reconciled or user-committed numeric value. */
429
+ /**
430
+ * The number shown last — reconciled or committed by the user — which the next
431
+ * move is measured from; `null` while the field is blank, and before this
432
+ * instance has shown one. A disconnect keeps it for the same instance
433
+ * connecting again.
434
+ */
369
435
  #lastValue = null;
436
+ /**
437
+ * Whether `connect()` has run and `disconnect()` has not since. The pass
438
+ * `connect()` runs takes the baseline without reporting it.
439
+ */
440
+ #connected = false;
370
441
  /** Timers for the hold delay and the suppress-reset safety net. */
371
442
  #holdTimeouts = new SafeTimeout();
372
443
  /** The running auto-repeat interval (one at a time). */
@@ -394,9 +465,16 @@ var NumberInputController = class _NumberInputController extends Controller {
394
465
  window.addEventListener("pointercancel", this.#onPointerEnd, { signal });
395
466
  window.addEventListener("blur", this.#onWindowBlur, { signal });
396
467
  this.#reconcile();
468
+ this.#connected = true;
397
469
  }
398
- /** Releases listeners, derived attributes, timers, and transient value state. */
470
+ /**
471
+ * Releases listeners, derived attributes, timers, and the hold state. The number
472
+ * shown last stays: the pass the next `connect()` runs takes a new one without a
473
+ * report from an input that is present, and with none it stays the number an
474
+ * input arriving later is compared with.
475
+ */
399
476
  disconnect() {
477
+ this.#connected = false;
400
478
  this.#repaint.cancel();
401
479
  this.#composition.disconnect();
402
480
  this.#beforeCache.deactivate();
@@ -418,7 +496,6 @@ var NumberInputController = class _NumberInputController extends Controller {
418
496
  this.#ariaValueNow.returnAll();
419
497
  this.#ariaValueMin.returnAll();
420
498
  this.#ariaValueMax.returnAll();
421
- this.#lastValue = null;
422
499
  }
423
500
  /** Reconciles the value against a minimum changed by application code or a Turbo morph. */
424
501
  minValueChanged() {
@@ -437,12 +514,15 @@ var NumberInputController = class _NumberInputController extends Controller {
437
514
  this.#composition.observe(input);
438
515
  this.#repaint.schedule();
439
516
  }
440
- /** Releases ARIA and composition state owned by an input that left the controller. */
517
+ /**
518
+ * Releases ARIA and composition state owned by an input that left the controller.
519
+ * The number it showed stays the baseline, so an input swapped in holding another
520
+ * number is reported as `reconcile`.
521
+ */
441
522
  inputTargetDisconnected(input) {
442
523
  this.#composition.unobserve(input);
443
524
  this.#releaseInputAria(input);
444
525
  this.#stopHold(false);
445
- this.#lastValue = null;
446
526
  this.#repaint.schedule();
447
527
  }
448
528
  /** Wires focus preservation and hold behavior on a runtime increment target. */
@@ -520,7 +600,7 @@ var NumberInputController = class _NumberInputController extends Controller {
520
600
  return;
521
601
  }
522
602
  event.preventDefault();
523
- this.#commit(next);
603
+ this.#commit(next, true);
524
604
  }
525
605
  /**
526
606
  * Wires a step button: `pointerdown` keeps focus on the input and arms the
@@ -624,42 +704,58 @@ var NumberInputController = class _NumberInputController extends Controller {
624
704
  *
625
705
  * @returns Whether the value changed (drives the auto-repeat's bound stop).
626
706
  */
627
- #commit(raw) {
707
+ #commit(raw, report = false) {
628
708
  const value = this.#normalize(raw);
629
709
  const changed = this.#lastValue === null || value !== this.#lastValue;
710
+ const shown = this.inputTarget.value;
630
711
  this.#write(value);
631
712
  this.#lastValue = value;
713
+ if (report && shown !== this.inputTarget.value) commitEdit(this.inputTarget);
632
714
  if (changed) this.dispatch("change", { detail: { value } });
633
715
  return changed;
634
716
  }
635
717
  /** Commits an adjacent endpoint/grid value and reports whether it moved. */
636
718
  #commitStep(count) {
637
719
  if (!this.hasInputTarget) return false;
638
- return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
720
+ return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange), true);
639
721
  }
640
722
  /**
641
- * Reflects the current value after a range or step morph, reporting a moved
642
- * value as `reconcile`.
723
+ * Reflects the current value after a range, step or input change, reporting a
724
+ * number that moved from the one shown last as `reconcile`. While the input is
725
+ * composing, its text is the IME's: the pass waits for the composition to end
726
+ * instead of reading and rewriting it. With no input there is nothing to show,
727
+ * and the number shown last stays the baseline for the input that comes next.
643
728
  *
644
729
  * @stimeoRenderRoot
645
730
  */
646
731
  #reconcile() {
647
- if (!this.hasInputTarget) {
648
- this.#lastValue = null;
732
+ if (!this.hasInputTarget) return;
733
+ if (this.#composition.isComposing()) {
734
+ this.#reconcileHeld = true;
649
735
  return;
650
736
  }
737
+ this.#reconcileHeld = false;
738
+ const previous = this.#lastValue;
651
739
  if (this.inputTarget.value.trim() !== "") {
652
- const previous = this.#lastValue;
653
740
  const value = this.#normalize(this.#currentValue());
654
741
  this.#write(value);
655
742
  this.#lastValue = value;
656
- if (previous !== null && value !== previous) {
657
- this.dispatch("reconcile", { detail: { value } });
658
- }
659
743
  } else {
660
744
  this.#lastValue = null;
661
745
  this.#reflectEmpty();
662
746
  }
747
+ this.#reportMove(previous);
748
+ }
749
+ /**
750
+ * Reports the number shown as `reconcile` when a change the page made moved it
751
+ * from `previous`. The page moved it, so it is reported apart from `change`,
752
+ * which stays reserved for user edits. A blank field reports nothing, the way a
753
+ * user blanking it does: both events carry a number.
754
+ */
755
+ #reportMove(previous) {
756
+ const value = this.#lastValue;
757
+ if (!this.#connected || value === null || value === previous) return;
758
+ this.dispatch("reconcile", { detail: { value } });
663
759
  }
664
760
  /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
665
761
  #write(value) {
@@ -719,10 +815,17 @@ var NumberInputController = class _NumberInputController extends Controller {
719
815
  button.disabled = false;
720
816
  button.removeAttribute(OWNED_DISABLED);
721
817
  }
722
- /** The current numeric value, falling back to a finite min (else 0) when blank. */
818
+ /**
819
+ * The current numeric value, falling back to a finite min (else 0) when blank.
820
+ * Every read of the field's number comes through here, and the text is read
821
+ * through the shared half-width mapping: a text-type spinbutton holds whatever an
822
+ * IME confirmed, full-width digits and signs included (`34`, `-5`), while a
823
+ * native number field's value is already half-width or empty.
824
+ */
723
825
  #currentValue() {
724
- const parsed = Number(this.inputTarget.value);
725
- if (Number.isFinite(parsed) && this.inputTarget.value.trim() !== "") return parsed;
826
+ const text = toHalfWidth(this.inputTarget.value);
827
+ const parsed = Number(text);
828
+ if (Number.isFinite(parsed) && text.trim() !== "") return parsed;
726
829
  return Number.isFinite(this.minValue) ? this.minValue : 0;
727
830
  }
728
831
  /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
@@ -57,7 +57,44 @@ var DetachGate = class _DetachGate {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/listener_set.ts
61
+ var ListenerSet = class {
62
+ /** The generation every `add` joins until the next `dispose()`. */
63
+ #abort = new AbortController();
64
+ /**
65
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
66
+ *
67
+ * The set supplies the signal, so `options` carries everything else the DOM
68
+ * accepts — `capture` included, which has to match at release time and no
69
+ * longer has a second place to drift from.
70
+ */
71
+ add(target, type, handler, options) {
72
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
73
+ }
74
+ /**
75
+ * Releases every listener of the open generation, synchronously, and opens the
76
+ * next one. Idempotent, and safe before anything has been added.
77
+ */
78
+ dispose() {
79
+ this.#abort.abort();
80
+ this.#abort = new AbortController();
81
+ }
82
+ };
83
+
84
+ // src/utils/transient_hooks.ts
85
+ var TransientHooks = class {
86
+ #attributes;
87
+ constructor(options) {
88
+ this.#attributes = options.attributes;
89
+ }
90
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
91
+ reset(element) {
92
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
93
+ }
94
+ };
95
+
60
96
  // src/controllers/optimistic_controller.ts
97
+ var TRANSIENT = new TransientHooks({ attributes: ["data-optimistic"] });
61
98
  var HIDDEN_MARKER = "data-optimistic-toggled";
62
99
  var BUSY_MARKER = "data-optimistic-busy";
63
100
  var AUTHORED_ABSENT = "absent";
@@ -73,6 +110,7 @@ var OptimisticController = class extends Controller {
73
110
  /** The submission that owns the applied optimistic state, if any. */
74
111
  #pending = null;
75
112
  #gate = new DetachGate();
113
+ #listeners = new ListenerSet();
76
114
  /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
77
115
  #connected = false;
78
116
  connect() {
@@ -80,13 +118,12 @@ var OptimisticController = class extends Controller {
80
118
  this.#gate.cancel();
81
119
  this.#connected = true;
82
120
  if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
83
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
84
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
121
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
122
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
85
123
  }
86
124
  disconnect() {
87
125
  this.#connected = false;
88
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
89
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
126
+ this.#listeners.dispose();
90
127
  this.#gate.disconnected(this, () => {
91
128
  this.#pending = null;
92
129
  });
@@ -165,7 +202,7 @@ var OptimisticController = class extends Controller {
165
202
  * received.
166
203
  */
167
204
  #revert() {
168
- this.element.removeAttribute("data-optimistic");
205
+ TRANSIENT.reset(this.element);
169
206
  this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
170
207
  const hidden = new Set(this.hideTargets);
171
208
  for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {