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
@@ -106,11 +106,19 @@ var BeforeCacheReset = class _BeforeCacheReset {
106
106
  };
107
107
 
108
108
  // src/utils/composition_tracker.ts
109
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
110
+ "insertCompositionText",
111
+ "insertFromComposition",
112
+ "deleteCompositionText",
113
+ "deleteByComposition"
114
+ ]);
109
115
  var CompositionTracker = class {
110
116
  #observedTargets = /* @__PURE__ */ new Set();
111
117
  #activeTargets = /* @__PURE__ */ new Set();
112
118
  #onStart;
113
119
  #onEnd;
120
+ /** The field whose confirming `input` is still owed, while the window is open. */
121
+ #confirmedTarget = null;
114
122
  constructor(options = {}) {
115
123
  this.#onStart = options.onStart;
116
124
  this.#onEnd = options.onEnd;
@@ -120,6 +128,7 @@ var CompositionTracker = class {
120
128
  if (this.#observedTargets.has(target)) return;
121
129
  target.addEventListener("compositionstart", this.#handleStart);
122
130
  target.addEventListener("compositionend", this.#handleEnd);
131
+ target.addEventListener("keydown", this.#handleKeydown);
123
132
  this.#observedTargets.add(target);
124
133
  }
125
134
  /** Stops tracking one target and clears any active composition it owned. */
@@ -127,29 +136,52 @@ var CompositionTracker = class {
127
136
  if (!this.#observedTargets.delete(target)) return;
128
137
  target.removeEventListener("compositionstart", this.#handleStart);
129
138
  target.removeEventListener("compositionend", this.#handleEnd);
139
+ target.removeEventListener("keydown", this.#handleKeydown);
130
140
  this.#activeTargets.delete(target);
141
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
131
142
  }
132
143
  /** Releases every listener and clears state so reconnect starts cleanly. */
133
144
  disconnect() {
134
145
  for (const target of this.#observedTargets) {
135
146
  target.removeEventListener("compositionstart", this.#handleStart);
136
147
  target.removeEventListener("compositionend", this.#handleEnd);
148
+ target.removeEventListener("keydown", this.#handleKeydown);
137
149
  }
138
150
  this.#observedTargets.clear();
139
151
  this.#activeTargets.clear();
152
+ this.#confirmedTarget = null;
140
153
  }
141
154
  /** True when lifecycle tracking or the current event reports composition. */
142
155
  isComposing(event) {
143
156
  return this.#activeTargets.size > 0 || event?.isComposing === true;
144
157
  }
158
+ /**
159
+ * Whether `event` is the `input` echoing the composition just confirmed.
160
+ *
161
+ * Asking closes the window either way, so one confirmation is folded at most
162
+ * once and a consumer asks once per `input`.
163
+ */
164
+ consumesConfirmedInput(event) {
165
+ const confirmed = this.#confirmedTarget;
166
+ this.#confirmedTarget = null;
167
+ if (confirmed === null || confirmed !== event.target) return false;
168
+ const inputType = event.inputType;
169
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
170
+ }
145
171
  #handleStart = (event) => {
172
+ this.#confirmedTarget = null;
146
173
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
147
174
  this.#onStart?.(event);
148
175
  };
149
176
  #handleEnd = (event) => {
150
177
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
178
+ this.#confirmedTarget = event.target;
151
179
  this.#onEnd?.(event);
152
180
  };
181
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
182
+ #handleKeydown = () => {
183
+ this.#confirmedTarget = null;
184
+ };
153
185
  };
154
186
 
155
187
  // src/utils/declared_value.ts
@@ -171,6 +203,54 @@ function compileRegExp(source, anchor = "none") {
171
203
  );
172
204
  }
173
205
 
206
+ // src/utils/form_reset_watcher.ts
207
+ var FormResetWatcher = class {
208
+ #owns;
209
+ #onReset;
210
+ #frames = /* @__PURE__ */ new Set();
211
+ #generation = 0;
212
+ /**
213
+ * @param owns - whether `form` owns a control this controller derives from.
214
+ * @param onReset - the reconciliation pass, run once per honoured reset.
215
+ */
216
+ constructor(owns, onReset) {
217
+ this.#owns = owns;
218
+ this.#onReset = onReset;
219
+ }
220
+ /** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
221
+ observe() {
222
+ document.addEventListener("reset", this.#handleReset, true);
223
+ }
224
+ /** Unsubscribes and drops every pending pass; call from `disconnect()`. */
225
+ disconnect() {
226
+ document.removeEventListener("reset", this.#handleReset, true);
227
+ this.#generation += 1;
228
+ for (const frame of this.#frames) cancelAnimationFrame(frame);
229
+ this.#frames.clear();
230
+ }
231
+ #handleReset = (event) => {
232
+ const form = event.target;
233
+ if (!(form instanceof HTMLFormElement) || !this.#owns(form)) return;
234
+ const generation = this.#generation;
235
+ queueMicrotask(() => {
236
+ if (generation !== this.#generation) return;
237
+ if (event.eventPhase === Event.NONE) {
238
+ this.#settle(event);
239
+ return;
240
+ }
241
+ const frame = requestAnimationFrame(() => {
242
+ this.#frames.delete(frame);
243
+ this.#settle(event);
244
+ });
245
+ this.#frames.add(frame);
246
+ });
247
+ };
248
+ /** Runs the pass unless a listener cancelled the reset before it landed. */
249
+ #settle(event) {
250
+ if (!event.defaultPrevented) this.#onReset();
251
+ }
252
+ };
253
+
174
254
  // src/utils/half_width.ts
175
255
  var FULL_WIDTH_SHIFT = 65248;
176
256
  function halfWidthChar(char) {
@@ -219,6 +299,27 @@ var MicrotaskCoalescer = class {
219
299
  };
220
300
 
221
301
  // src/controllers/otp_controller.ts
302
+ var LEASE_MARKERS = {
303
+ "aria-invalid": "data-otp-invalid-lease",
304
+ "aria-errormessage": "data-otp-errormessage-lease",
305
+ "aria-describedby": "data-otp-describedby-lease"
306
+ };
307
+ function isErrorLease(value) {
308
+ const lease = value;
309
+ return typeof lease?.written === "string" && (lease.authored === null || typeof lease.authored === "string");
310
+ }
311
+ function readLease(field, marker) {
312
+ try {
313
+ const parsed = JSON.parse(field.getAttribute(marker) ?? "null");
314
+ return isErrorLease(parsed) ? parsed : null;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+ function describedByWith(described, errorId) {
320
+ const tokens = (described ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
321
+ return [...tokens, errorId].join(" ");
322
+ }
222
323
  var DEFAULT_PATTERN = "[0-9]";
223
324
  var DEFAULT_MATCHER = new RegExp(`^(?:${DEFAULT_PATTERN})$`);
224
325
  function hasModifier(event) {
@@ -240,38 +341,59 @@ var OtpController = class extends Controller {
240
341
  #patternSource = DEFAULT_PATTERN;
241
342
  /** Public state carried by the last dispatch; keeps a no-op sync silent. */
242
343
  #published = null;
243
- /** Field whose confirming `input` after `compositionend` is already handled. */
244
- #confirmedField = null;
245
344
  /** True between connect and disconnect, so pre-connect Value changes stay silent. */
246
345
  #connected = false;
247
346
  /** Digit each field last committed, restored when rejected input replaced it. */
248
347
  #committed = /* @__PURE__ */ new WeakMap();
249
- /** Collapses one batch of field target callbacks into a single reconciliation. */
348
+ /** The field being composed, and the text it held when the composition began. */
349
+ #composing = null;
350
+ /** A `pattern` change whose rejected characters wait for the composition to end. */
351
+ #dropHeld = false;
352
+ /**
353
+ * Collapses one batch of page changes — field target and `pattern` callbacks, a
354
+ * form reset — into a single reconciliation.
355
+ */
250
356
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
251
- #ariaInvalid = new AttributeLease("aria-invalid");
252
- #ariaErrorMessage = new AttributeLease("aria-errormessage");
253
- #ariaDescribedBy = new AttributeLease("aria-describedby");
254
357
  #errorHidden = new AttributeLease("hidden");
255
358
  #state = new AttributeLease("data-state");
256
359
  /** Rewinds the transient error surface before Turbo freezes the page. */
257
360
  #beforeCache = new BeforeCacheReset(() => this.#clearError());
258
- /** Owns IME lifecycle state across every digit field. */
361
+ /** Reads the fields back once a native reset restored them, as a reconciliation. */
362
+ #formReset = new FormResetWatcher(
363
+ (form) => this.#ownedBy(form),
364
+ () => {
365
+ this.#adopt();
366
+ this.#reconcile.schedule();
367
+ }
368
+ );
369
+ /**
370
+ * Owns IME lifecycle state across every digit field. When a composition ends, a
371
+ * `pattern` drop it held back applies first — it was declared first — and the
372
+ * user's commit is taken after it.
373
+ */
259
374
  #composition = new CompositionTracker({
260
- onStart: () => {
261
- this.#confirmedField = null;
375
+ onStart: (event) => {
376
+ const field = this.#fieldFrom(event);
377
+ if (field) this.#composing = { field, before: field.value };
262
378
  },
263
379
  onEnd: (event) => {
380
+ const composing = this.#composing;
381
+ this.#composing = null;
264
382
  const input = this.#fieldFrom(event);
265
- if (!input) return;
266
- this.#confirmedField = input;
267
- const committed = event.data ?? "";
268
- this.#accept(input, committed.length > input.value.length ? committed : input.value);
383
+ const data = event.data ?? "";
384
+ const text = input && data.length <= input.value.length ? input.value : data;
385
+ const commit = input !== null && (composing?.field !== input || text !== composing.before);
386
+ if (this.#dropHeld) {
387
+ if (input && commit) input.value = this.#committed.get(input) ?? "";
388
+ this.#reconcileFields();
389
+ }
390
+ if (input && commit) this.#accept(input, text);
269
391
  }
270
392
  });
271
393
  connect() {
272
394
  this.#connected = true;
273
395
  for (const field of this.fieldTargets) this.#bind(field);
274
- document.addEventListener("reset", this.#onReset, true);
396
+ this.#formReset.observe();
275
397
  this.#beforeCache.activate();
276
398
  this.#reconcile.activate();
277
399
  this.#adopt();
@@ -281,9 +403,10 @@ var OtpController = class extends Controller {
281
403
  this.#connected = false;
282
404
  for (const field of this.fieldTargets) this.#unbind(field);
283
405
  this.#composition.disconnect();
284
- document.removeEventListener("reset", this.#onReset, true);
406
+ this.#formReset.disconnect();
285
407
  this.#beforeCache.deactivate();
286
408
  this.#reconcile.cancel();
409
+ this.#dropHeld = false;
287
410
  this.#clearError();
288
411
  this.#state.return(this.element);
289
412
  }
@@ -300,18 +423,29 @@ var OtpController = class extends Controller {
300
423
  /** Releases a dropped field's listeners and leases, then reconciles the rest. */
301
424
  fieldTargetDisconnected(element) {
302
425
  this.#unbind(element);
303
- this.#returnFieldLeases(element);
426
+ this.#returnLeases(element);
304
427
  this.#reconcile.schedule();
305
428
  }
306
429
  /**
307
430
  * Re-validates a changed `pattern` declaration once and drops any entered digit
308
431
  * the new pattern no longer accepts, so the combined value stays interpretable.
432
+ * The page changed the declaration, so the move joins the same reconciliation
433
+ * as fields added or removed in that mutation. While a field is composing, its
434
+ * text is the IME's, and the drop waits for the composition to end.
309
435
  */
310
436
  patternValueChanged() {
311
437
  const compiled = compileRegExp(this.patternValue, "exact");
312
438
  this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
313
439
  this.#pattern = compiled ?? DEFAULT_MATCHER;
314
440
  if (!this.#connected) return;
441
+ if (this.#composing !== null) {
442
+ this.#dropHeld = true;
443
+ return;
444
+ }
445
+ if (this.#dropRejected()) this.#reconcile.schedule();
446
+ }
447
+ /** Empties every writable field holding a character the pattern rejects; whether one did. */
448
+ #dropRejected() {
315
449
  let dropped = false;
316
450
  for (const field of this.fieldTargets) {
317
451
  if (field.value === "" || !this.#isWritable(field)) continue;
@@ -319,15 +453,13 @@ var OtpController = class extends Controller {
319
453
  this.#writeField(field, "");
320
454
  dropped = true;
321
455
  }
322
- if (dropped) this.#syncAndDispatch();
456
+ return dropped;
323
457
  }
324
458
  /** Handles keystroke inputs, distributes autofilled text, and advances focus. */
325
459
  onInput(event) {
326
460
  const input = this.#fieldFrom(event);
327
461
  if (!input) return;
328
- const confirmed = this.#confirmedField;
329
- this.#confirmedField = null;
330
- if (confirmed === input) return;
462
+ if (this.#composition.consumesConfirmedInput(event)) return;
331
463
  if (this.#composition.isComposing(event)) return;
332
464
  this.#accept(input);
333
465
  }
@@ -336,7 +468,6 @@ var OtpController = class extends Controller {
336
468
  if (isReservedArrowChord(event)) return;
337
469
  const input = this.#fieldFrom(event);
338
470
  if (!input) return;
339
- this.#confirmedField = null;
340
471
  if (this.#composition.isComposing(event)) return;
341
472
  const index = this.fieldTargets.indexOf(input);
342
473
  const fields = this.fieldTargets;
@@ -400,7 +531,6 @@ var OtpController = class extends Controller {
400
531
  const input = this.#fieldFrom(event);
401
532
  if (!input) return;
402
533
  event.preventDefault();
403
- this.#confirmedField = null;
404
534
  this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
405
535
  }
406
536
  /**
@@ -431,16 +561,6 @@ var OtpController = class extends Controller {
431
561
  input.select();
432
562
  }
433
563
  };
434
- /** Reconciles derived state after a non-cancelled reset restores the fields. */
435
- #onReset = (event) => {
436
- const form = event.target;
437
- if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
438
- queueMicrotask(() => {
439
- if (event.defaultPrevented) return;
440
- this.#adopt();
441
- this.#syncAndDispatch();
442
- });
443
- };
444
564
  /** Whether a form owns at least one field or the hidden combined value. */
445
565
  #ownedBy(form) {
446
566
  if (this.fieldTargets.some((field) => field.form === form)) return true;
@@ -453,7 +573,7 @@ var OtpController = class extends Controller {
453
573
  #unbind(field) {
454
574
  field.removeEventListener("focus", this.#onFieldFocus);
455
575
  this.#composition.unobserve(field);
456
- if (this.#confirmedField === field) this.#confirmedField = null;
576
+ if (this.#composing?.field === field) this.#composing = null;
457
577
  }
458
578
  /** Reads every field back so a restored or reset group starts consistent. */
459
579
  #adopt() {
@@ -461,25 +581,55 @@ var OtpController = class extends Controller {
461
581
  this.#clearError();
462
582
  if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
463
583
  }
584
+ /** Writes one error attribute, keeping the first authored value it displaces. */
585
+ #lease(field, attribute, value) {
586
+ const marker = LEASE_MARKERS[attribute];
587
+ const held = readLease(field, marker);
588
+ const authored = held ? held.authored : field.getAttribute(attribute);
589
+ field.setAttribute(marker, JSON.stringify({ authored, written: value }));
590
+ field.setAttribute(attribute, value);
591
+ }
592
+ /** Gives one field's error attributes back where they still hold what this wrote. */
593
+ #returnLeases(field) {
594
+ for (const [attribute, marker] of Object.entries(LEASE_MARKERS)) {
595
+ const held = readLease(field, marker);
596
+ if (!held) continue;
597
+ field.removeAttribute(marker);
598
+ if (field.getAttribute(attribute) !== held.written) continue;
599
+ if (held.authored === null) field.removeAttribute(attribute);
600
+ else field.setAttribute(attribute, held.authored);
601
+ }
602
+ }
464
603
  /** Takes one field's current value as the truth behind its derived state. */
465
604
  #adoptField(field) {
466
605
  this.#committed.set(field, field.value);
467
606
  this.#markFilled(field, field.value);
468
607
  }
469
608
  /**
470
- * Absorbs a batch of field additions or removals as one state transition.
609
+ * Absorbs one batch of page changes — fields added or removed, entered digits a
610
+ * new `pattern` rejects, fields a native form reset restored — as one state
611
+ * transition.
471
612
  *
472
- * The page, not the user, moved the state here, so it is reported as
473
- * `reconcile`: automation listening for `change` must not read a re-render as
474
- * an edit, and a passcode that happens to end up full must not fire the
613
+ * Nobody edited the passcode here, so the move is reported as `reconcile`:
614
+ * automation listening for `change` must not read a re-render or a reset as an
615
+ * edit, and a passcode that happens to end up full must not fire the
475
616
  * `complete` that submits it.
476
617
  *
477
618
  * Completeness moves on its own when the field count changes: dropping a
478
619
  * trailing empty field completes a passcode whose combined value never moved,
479
620
  * and adding one un-completes it. Comparing the whole derived state, not the
480
621
  * string it contains, is what makes those transitions reportable.
622
+ *
623
+ * A change already in the DOM — a field added or removed, a reset — is
624
+ * published at once, even while a field is composing: that field counts by the
625
+ * character it had committed, never by the IME's uncommitted text. Only a
626
+ * `pattern` drop waits for the composition to end.
481
627
  */
482
628
  #reconcileFields() {
629
+ if (this.#dropHeld && this.#composing === null) {
630
+ this.#dropHeld = false;
631
+ this.#dropRejected();
632
+ }
483
633
  const previous = this.#published;
484
634
  const current = this.#sync();
485
635
  if (previous && !statesDiffer(previous, current)) return;
@@ -571,12 +721,16 @@ var OtpController = class extends Controller {
571
721
  return this.fieldTargets.find((field) => field === input) ?? null;
572
722
  }
573
723
  #combinedValue() {
574
- return this.fieldTargets.map((field) => field.value).join("");
724
+ return this.fieldTargets.map((field) => this.#publishedText(field)).join("");
575
725
  }
576
726
  /** Every field carries a character, and there is at least one field. */
577
727
  #isComplete() {
578
728
  const fields = this.fieldTargets;
579
- return fields.length > 0 && fields.every((field) => field.value.length > 0);
729
+ return fields.length > 0 && fields.every((field) => this.#publishedText(field).length > 0);
730
+ }
731
+ /** A field's text as the combined value counts it: a composing field by its committed character. */
732
+ #publishedText(field) {
733
+ return field === this.#composing?.field ? this.#committed.get(field) ?? "" : field.value;
580
734
  }
581
735
  /**
582
736
  * Mirrors the combined value into the form and the root's readable state, and
@@ -610,31 +764,20 @@ var OtpController = class extends Controller {
610
764
  #showError() {
611
765
  const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
612
766
  for (const field of this.fieldTargets) {
613
- this.#ariaInvalid.write(field, "true");
767
+ this.#lease(field, "aria-invalid", "true");
614
768
  if (!errorId) continue;
615
- this.#ariaErrorMessage.write(field, errorId);
616
- this.#ariaDescribedBy.write(field, this.#describedByWith(field, errorId));
769
+ this.#lease(field, "aria-errormessage", errorId);
770
+ const described = field.getAttribute("aria-describedby");
771
+ this.#lease(field, "aria-describedby", describedByWith(described, errorId));
617
772
  }
618
773
  if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
619
774
  this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
620
775
  }
621
776
  /** Returns every error lease, restoring the authored error surface. */
622
777
  #clearError() {
623
- this.#ariaInvalid.returnAll();
624
- this.#ariaErrorMessage.returnAll();
625
- this.#ariaDescribedBy.returnAll();
778
+ for (const field of this.fieldTargets) this.#returnLeases(field);
626
779
  this.#errorHidden.returnAll();
627
780
  }
628
- #returnFieldLeases(field) {
629
- this.#ariaInvalid.return(field);
630
- this.#ariaErrorMessage.return(field);
631
- this.#ariaDescribedBy.return(field);
632
- }
633
- /** The field's own description tokens with the error id appended once. */
634
- #describedByWith(field, errorId) {
635
- const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
636
- return [...tokens, errorId].join(" ");
637
- }
638
781
  };
639
782
 
640
783
  export { OtpController };