stimeo-ui 0.7.0 → 0.8.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.
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/otp_controller.ts
4
4
 
5
+ // src/utils/aria_ids.ts
6
+ var counter = 0;
7
+ function uniqueId(prefix = "stimeo") {
8
+ let candidate;
9
+ do {
10
+ counter += 1;
11
+ candidate = `${prefix}-${counter}`;
12
+ } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
+ return candidate;
14
+ }
15
+ function ensureId(element, prefix = "stimeo") {
16
+ if (element.id) return element.id;
17
+ const id = uniqueId(prefix);
18
+ element.id = id;
19
+ return id;
20
+ }
21
+
5
22
  // src/utils/logical_scroll.ts
6
23
  function isRtl(element) {
7
24
  return window.getComputedStyle(element).direction === "rtl";
@@ -18,6 +35,76 @@ function isReservedArrowChord(event, allow = []) {
18
35
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
36
  }
20
37
 
38
+ // src/utils/attribute_lease.ts
39
+ var AttributeLease = class {
40
+ #attribute;
41
+ #records = /* @__PURE__ */ new Map();
42
+ /** @param attribute - The attribute whose temporary values this lease owns. */
43
+ constructor(attribute) {
44
+ this.#attribute = attribute;
45
+ }
46
+ /** Writes or removes the leased attribute while preserving its authored value. */
47
+ write(element, value) {
48
+ const existing = this.#records.get(element);
49
+ if (existing) {
50
+ existing.written = value;
51
+ } else {
52
+ this.#records.set(element, {
53
+ original: element.getAttribute(this.#attribute),
54
+ written: value
55
+ });
56
+ }
57
+ this.#reflect(element, value);
58
+ }
59
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
60
+ return(element) {
61
+ const record = this.#records.get(element);
62
+ if (!record) return;
63
+ this.#records.delete(element);
64
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
65
+ if (stillOwned) this.#reflect(element, record.original);
66
+ }
67
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
68
+ #reflect(element, value) {
69
+ if (element.getAttribute(this.#attribute) === value) return;
70
+ if (value === null) element.removeAttribute(this.#attribute);
71
+ else element.setAttribute(this.#attribute, value);
72
+ }
73
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
74
+ returnAll() {
75
+ for (const element of Array.from(this.#records.keys())) this.return(element);
76
+ }
77
+ };
78
+
79
+ // src/utils/before_cache_reset.ts
80
+ var BeforeCacheReset = class _BeforeCacheReset {
81
+ /** Every subscribed instance, iterated by the one shared document listener. */
82
+ static #subscribers = /* @__PURE__ */ new Set();
83
+ /** The shared listener; installed while at least one instance is subscribed. */
84
+ static #onBeforeCache = () => {
85
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
86
+ };
87
+ #rewind;
88
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
89
+ constructor(rewind) {
90
+ this.#rewind = rewind;
91
+ }
92
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
93
+ activate() {
94
+ const first = _BeforeCacheReset.#subscribers.size === 0;
95
+ _BeforeCacheReset.#subscribers.add(this);
96
+ if (first) {
97
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
98
+ }
99
+ }
100
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
101
+ deactivate() {
102
+ _BeforeCacheReset.#subscribers.delete(this);
103
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
104
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
105
+ }
106
+ };
107
+
21
108
  // src/utils/composition_tracker.ts
22
109
  var CompositionTracker = class {
23
110
  #observedTargets = /* @__PURE__ */ new Set();
@@ -65,135 +152,261 @@ var CompositionTracker = class {
65
152
  };
66
153
  };
67
154
 
155
+ // src/utils/half_width.ts
156
+ var FULL_WIDTH_SHIFT = 65248;
157
+ function halfWidthChar(char) {
158
+ if (char >= "\uFF01" && char <= "\uFF5E") {
159
+ return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
160
+ }
161
+ return char === "\u3000" ? " " : char;
162
+ }
163
+ function toHalfWidth(text) {
164
+ let out = "";
165
+ for (const char of text) out += halfWidthChar(char);
166
+ return out;
167
+ }
168
+
169
+ // src/utils/microtask_coalescer.ts
170
+ var MicrotaskCoalescer = class {
171
+ #run;
172
+ #queued = false;
173
+ #active = false;
174
+ #generation = 0;
175
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
176
+ constructor(run) {
177
+ this.#run = run;
178
+ }
179
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
180
+ activate() {
181
+ this.#active = true;
182
+ }
183
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
184
+ cancel() {
185
+ this.#active = false;
186
+ this.#queued = false;
187
+ this.#generation += 1;
188
+ }
189
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
190
+ schedule() {
191
+ if (!this.#active || this.#queued) return;
192
+ this.#queued = true;
193
+ const generation = this.#generation;
194
+ queueMicrotask(() => {
195
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
196
+ this.#queued = false;
197
+ this.#run();
198
+ });
199
+ }
200
+ };
201
+
68
202
  // src/controllers/otp_controller.ts
203
+ var DEFAULT_PATTERN = "[0-9]";
204
+ function compilePattern(source) {
205
+ try {
206
+ return new RegExp(`^${source}$`);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+ function hasModifier(event) {
212
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
213
+ }
69
214
  var OtpController = class extends Controller {
70
215
  static targets = ["field", "value", "error"];
71
216
  static values = {
72
- length: { type: Number, default: 6 },
73
- pattern: { type: String, default: "[0-9]" }
217
+ pattern: { type: String, default: DEFAULT_PATTERN }
74
218
  };
75
- static actions = ["onInput", "onKeydown", "onPaste"];
76
- static events = ["change", "complete", "invalid"];
219
+ static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
220
+ static events = ["change", "complete", "invalid", "reconcile"];
221
+ /** Validated matcher; the hot path never compiles a raw declaration. */
222
+ #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
223
+ /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
224
+ #patternSource = DEFAULT_PATTERN;
225
+ /** Combined value carried by the last dispatch; keeps a no-op sync silent. */
226
+ #lastValue = null;
227
+ /** Field whose confirming `input` after `compositionend` is already handled. */
228
+ #confirmedField = null;
229
+ /** True between connect and disconnect, so pre-connect Value changes stay silent. */
230
+ #connected = false;
231
+ /** Digit each field last committed, restored when rejected input replaced it. */
232
+ #committed = /* @__PURE__ */ new WeakMap();
233
+ /** Collapses one batch of field target callbacks into a single reconciliation. */
234
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
235
+ #ariaInvalid = new AttributeLease("aria-invalid");
236
+ #ariaErrorMessage = new AttributeLease("aria-errormessage");
237
+ #ariaDescribedBy = new AttributeLease("aria-describedby");
238
+ #errorHidden = new AttributeLease("hidden");
239
+ #state = new AttributeLease("data-state");
240
+ /** Rewinds the transient error surface before Turbo freezes the page. */
241
+ #beforeCache = new BeforeCacheReset(() => this.#clearError());
77
242
  /** Owns IME lifecycle state across every digit field. */
78
243
  #composition = new CompositionTracker({
244
+ onStart: () => {
245
+ this.#confirmedField = null;
246
+ },
79
247
  onEnd: (event) => {
80
- const input = event.currentTarget;
81
- if (input) this.#handleInputValidation(input);
248
+ const input = this.#fieldFrom(event);
249
+ if (!input) return;
250
+ this.#confirmedField = input;
251
+ const committed = event.data ?? "";
252
+ this.#accept(input, committed.length > input.value.length ? committed : input.value);
82
253
  }
83
254
  });
84
255
  connect() {
85
- for (const field of this.fieldTargets) {
86
- field.addEventListener("focus", this.#onFieldFocus);
87
- this.#composition.observe(field);
88
- }
256
+ this.#connected = true;
257
+ for (const field of this.fieldTargets) this.#bind(field);
258
+ document.addEventListener("reset", this.#onReset, true);
259
+ this.#beforeCache.activate();
260
+ this.#reconcile.activate();
261
+ this.#adopt();
262
+ this.#lastValue = this.#sync();
89
263
  }
90
264
  disconnect() {
91
- for (const field of this.fieldTargets) {
92
- field.removeEventListener("focus", this.#onFieldFocus);
93
- }
265
+ this.#connected = false;
266
+ for (const field of this.fieldTargets) this.#unbind(field);
94
267
  this.#composition.disconnect();
268
+ document.removeEventListener("reset", this.#onReset, true);
269
+ this.#beforeCache.deactivate();
270
+ this.#reconcile.cancel();
271
+ this.#clearError();
272
+ this.#state.return(this.element);
95
273
  }
96
274
  /**
97
275
  * Stimulus lifecycle callback when a new field target enters the DOM.
98
- * Ensures new additions are also wired with overwriting support.
276
+ * Wires the new field and folds the wider digit count into one reconciliation,
277
+ * which stays inert until `connect()` opens the window.
99
278
  */
100
279
  fieldTargetConnected(element) {
101
- element.addEventListener("focus", this.#onFieldFocus);
102
- this.#composition.observe(element);
280
+ this.#bind(element);
281
+ this.#adoptField(element);
282
+ this.#reconcile.schedule();
103
283
  }
104
- /** Removes focus listeners when fields are dropped. */
284
+ /** Releases a dropped field's listeners and leases, then reconciles the rest. */
105
285
  fieldTargetDisconnected(element) {
106
- element.removeEventListener("focus", this.#onFieldFocus);
107
- this.#composition.unobserve(element);
286
+ this.#unbind(element);
287
+ this.#returnFieldLeases(element);
288
+ this.#reconcile.schedule();
289
+ }
290
+ /**
291
+ * Re-validates a changed `pattern` declaration once and drops any entered digit
292
+ * the new pattern no longer accepts, so the combined value stays interpretable.
293
+ */
294
+ patternValueChanged() {
295
+ const compiled = compilePattern(this.patternValue);
296
+ this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
297
+ this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
298
+ if (!this.#connected) return;
299
+ let dropped = false;
300
+ for (const field of this.fieldTargets) {
301
+ if (field.value === "" || !this.#isWritable(field)) continue;
302
+ if (this.#pattern.test(field.value)) continue;
303
+ this.#writeField(field, "");
304
+ dropped = true;
305
+ }
306
+ if (dropped) this.#syncAndDispatch();
108
307
  }
109
- /** Handles keystroke inputs and advances focus to the next field. */
308
+ /** Handles keystroke inputs, distributes autofilled text, and advances focus. */
110
309
  onInput(event) {
111
- const input = event.currentTarget;
310
+ const input = this.#fieldFrom(event);
112
311
  if (!input) return;
312
+ const confirmed = this.#confirmedField;
313
+ this.#confirmedField = null;
314
+ if (confirmed === input) return;
113
315
  if (this.#composition.isComposing(event)) return;
114
- this.#handleInputValidation(input);
316
+ this.#accept(input);
115
317
  }
116
- /** Handles Backspace retreating, arrows, and home/end navigation. */
318
+ /** Handles Backspace clearing, arrows, and home/end navigation. */
117
319
  onKeydown(event) {
118
320
  if (isReservedArrowChord(event)) return;
119
- const input = event.currentTarget;
321
+ const input = this.#fieldFrom(event);
120
322
  if (!input) return;
121
- const index = this.fieldTargets.indexOf(input);
122
- if (index === -1) return;
323
+ this.#confirmedField = null;
123
324
  if (this.#composition.isComposing(event)) return;
325
+ const index = this.fieldTargets.indexOf(input);
326
+ const fields = this.fieldTargets;
124
327
  switch (logicalArrowKey(event.key, this.element)) {
125
328
  case "Backspace":
126
- if (!input.value) {
127
- if (index > 0) {
128
- event.preventDefault();
129
- const prevField = this.fieldTargets[index - 1];
130
- if (prevField) {
131
- prevField.value = "";
132
- prevField.removeAttribute("data-filled");
133
- prevField.focus();
134
- this.#clearError();
135
- this.#syncAndDispatch();
136
- }
137
- }
138
- } else {
139
- input.value = "";
140
- input.removeAttribute("data-filled");
329
+ if (hasModifier(event)) break;
330
+ if (input.value && this.#isWritable(input)) {
331
+ event.preventDefault();
332
+ this.#writeField(input, "");
141
333
  this.#clearError();
142
334
  this.#syncAndDispatch();
335
+ } else {
336
+ const previous = this.#writableBefore(index);
337
+ if (previous) {
338
+ event.preventDefault();
339
+ this.#writeField(previous, "");
340
+ previous.focus();
341
+ this.#clearError();
342
+ this.#syncAndDispatch();
343
+ }
143
344
  }
144
345
  break;
145
- case "ArrowLeft":
146
- if (index > 0) {
346
+ case "ArrowLeft": {
347
+ const previous = this.#focusableBefore(index);
348
+ if (previous) {
147
349
  event.preventDefault();
148
- this.fieldTargets[index - 1]?.focus();
350
+ previous.focus();
149
351
  }
150
352
  break;
151
- case "ArrowRight":
152
- if (index < this.lengthValue - 1) {
353
+ }
354
+ case "ArrowRight": {
355
+ const next = this.#focusableAfter(index);
356
+ if (next) {
153
357
  event.preventDefault();
154
- this.fieldTargets[index + 1]?.focus();
358
+ next.focus();
155
359
  }
156
360
  break;
157
- case "Home":
158
- event.preventDefault();
159
- this.fieldTargets[0]?.focus();
361
+ }
362
+ case "Home": {
363
+ if (hasModifier(event)) break;
364
+ const first = fields.find((field) => this.#isFocusable(field));
365
+ if (first) {
366
+ event.preventDefault();
367
+ first.focus();
368
+ }
160
369
  break;
161
- case "End":
162
- event.preventDefault();
163
- this.fieldTargets[this.lengthValue - 1]?.focus();
370
+ }
371
+ case "End": {
372
+ if (hasModifier(event)) break;
373
+ const last = fields.slice().reverse().find((field) => this.#isFocusable(field));
374
+ if (last) {
375
+ event.preventDefault();
376
+ last.focus();
377
+ }
164
378
  break;
379
+ }
165
380
  }
166
381
  }
167
- /** Divides pasted string characters across available input fields. */
382
+ /** Divides pasted string characters across the available input fields. */
168
383
  onPaste(event) {
169
- const input = event.currentTarget;
384
+ const input = this.#fieldFrom(event);
170
385
  if (!input) return;
171
- const startIndex = this.fieldTargets.indexOf(input);
172
- if (startIndex === -1) return;
173
386
  event.preventDefault();
174
- const rawText = event.clipboardData?.getData("text") || "";
175
- const text = this.#normalizeValue(rawText);
176
- const regex = new RegExp(`^${this.patternValue}$`);
177
- const validChars = Array.from(text).filter((char) => regex.test(char));
178
- if (validChars.length === 0) {
179
- this.#showError();
180
- return;
181
- }
182
- const limit = Math.min(validChars.length, this.lengthValue - startIndex);
183
- let lastFocusedIndex = startIndex;
184
- for (let i = 0; i < limit; i++) {
185
- const fieldIndex = startIndex + i;
186
- const field = this.fieldTargets[fieldIndex];
187
- const char = validChars[i];
188
- if (field && char) {
189
- field.value = char;
190
- field.setAttribute("data-filled", "true");
191
- lastFocusedIndex = fieldIndex;
192
- }
387
+ this.#confirmedField = null;
388
+ this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
389
+ }
390
+ /**
391
+ * Redirects a pointer landing on an empty field to the earliest empty one, so
392
+ * a passcode is entered in order. Filled fields stay directly reachable for
393
+ * correction, and keyboard focus is left alone.
394
+ */
395
+ onPointerDown(event) {
396
+ const input = this.#fieldFrom(event);
397
+ if (input?.value !== "") return;
398
+ const first = this.fieldTargets.find((field) => this.#isWritable(field) && field.value === "");
399
+ if (!first || first === input) return;
400
+ event.preventDefault();
401
+ first.focus();
402
+ }
403
+ /** Empties every writable field and restarts entry at the first of them. */
404
+ clear() {
405
+ for (const field of this.fieldTargets) {
406
+ if (this.#isWritable(field)) this.#writeField(field, "");
193
407
  }
194
408
  this.#clearError();
195
- const focusTargetIndex = lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;
196
- this.fieldTargets[focusTargetIndex]?.focus();
409
+ this.fieldTargets.find((field) => this.#isWritable(field))?.focus();
197
410
  this.#syncAndDispatch();
198
411
  }
199
412
  #onFieldFocus = (event) => {
@@ -202,55 +415,200 @@ var OtpController = class extends Controller {
202
415
  input.select();
203
416
  }
204
417
  };
205
- #handleInputValidation(input) {
206
- const index = this.fieldTargets.indexOf(input);
207
- if (index === -1) return;
208
- const rawValue = input.value;
209
- const normalized = this.#normalizeValue(rawValue);
210
- const regex = new RegExp(`^${this.patternValue}$`);
211
- if (normalized && regex.test(normalized)) {
212
- input.value = normalized;
213
- input.setAttribute("data-filled", "true");
418
+ /** Reconciles derived state after a non-cancelled reset restores the fields. */
419
+ #onReset = (event) => {
420
+ const form = event.target;
421
+ if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
422
+ queueMicrotask(() => {
423
+ if (event.defaultPrevented) return;
424
+ this.#adopt();
425
+ this.#syncAndDispatch();
426
+ });
427
+ };
428
+ /** Whether a form owns at least one field or the hidden combined value. */
429
+ #ownedBy(form) {
430
+ if (this.fieldTargets.some((field) => field.form === form)) return true;
431
+ return this.hasValueTarget && this.valueTarget.form === form;
432
+ }
433
+ #bind(field) {
434
+ field.addEventListener("focus", this.#onFieldFocus);
435
+ this.#composition.observe(field);
436
+ }
437
+ #unbind(field) {
438
+ field.removeEventListener("focus", this.#onFieldFocus);
439
+ this.#composition.unobserve(field);
440
+ if (this.#confirmedField === field) this.#confirmedField = null;
441
+ }
442
+ /** Reads every field back so a restored or reset group starts consistent. */
443
+ #adopt() {
444
+ for (const field of this.fieldTargets) this.#adoptField(field);
445
+ this.#clearError();
446
+ if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
447
+ }
448
+ /** Takes one field's current value as the truth behind its derived state. */
449
+ #adoptField(field) {
450
+ this.#committed.set(field, field.value);
451
+ this.#markFilled(field, field.value);
452
+ }
453
+ /**
454
+ * Absorbs a batch of field additions or removals as one value transition.
455
+ *
456
+ * The page, not the user, moved the value here, so it is reported as
457
+ * `reconcile`: automation listening for `change` must not read a re-render as
458
+ * an edit, and a passcode that happens to end up full must not fire the
459
+ * `complete` that submits it.
460
+ */
461
+ #reconcileFields() {
462
+ const previous = this.#lastValue;
463
+ const combined = this.#sync();
464
+ if (combined === previous) return;
465
+ this.#lastValue = combined;
466
+ this.dispatch("reconcile", { detail: { value: combined } });
467
+ }
468
+ /**
469
+ * Validates the text an entry point received and distributes what it accepts.
470
+ * `text` defaults to the field's own value; a confirmation passes the string it
471
+ * committed, which `maxlength` would otherwise have truncated.
472
+ */
473
+ #accept(input, text = input.value) {
474
+ const raw = toHalfWidth(text);
475
+ if (raw === "") {
476
+ this.#writeField(input, "");
214
477
  this.#clearError();
215
- if (index < this.lengthValue - 1) {
216
- const nextField = this.fieldTargets[index + 1];
217
- nextField?.focus();
218
- }
219
- } else if (normalized) {
220
- input.value = "";
221
- input.removeAttribute("data-filled");
478
+ this.#syncAndDispatch();
479
+ return;
480
+ }
481
+ this.#distribute(input, raw);
482
+ }
483
+ /**
484
+ * Fills `text`'s accepted characters into the writable fields at and after the
485
+ * entry point, then leaves focus on the field after the last one filled.
486
+ */
487
+ #distribute(from, text) {
488
+ const accepted = Array.from(text).filter((char) => this.#pattern.test(char));
489
+ let reached = false;
490
+ const slots = this.fieldTargets.filter((field) => {
491
+ reached ||= field === from;
492
+ return reached && this.#isWritable(field);
493
+ });
494
+ const filled = Math.min(accepted.length, slots.length);
495
+ if (filled === 0) {
496
+ this.#restore(from);
222
497
  this.#showError();
223
- } else {
224
- input.removeAttribute("data-filled");
498
+ this.#sync();
499
+ return;
500
+ }
501
+ for (let i = 0; i < filled; i++) {
502
+ const field = slots[i];
503
+ const char = accepted[i];
504
+ if (field && char) this.#writeField(field, char);
225
505
  }
506
+ const last = slots[filled - 1];
507
+ if (last) (this.#writableAfter(last) ?? last).focus();
508
+ this.#clearError();
226
509
  this.#syncAndDispatch();
227
510
  }
228
- #normalizeValue(val) {
229
- return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
511
+ /** Restores the digit a field committed before rejected input replaced it. */
512
+ #restore(field) {
513
+ this.#writeField(field, this.#committed.get(field) ?? "");
230
514
  }
231
- #showError() {
232
- if (this.hasErrorTarget) {
233
- this.errorTarget.removeAttribute("hidden");
234
- }
235
- this.dispatch("invalid", { detail: { pattern: this.patternValue } });
515
+ /** Commits one field's value and the derived hook that reports it as entered. */
516
+ #writeField(field, value) {
517
+ field.value = value;
518
+ this.#committed.set(field, value);
519
+ this.#markFilled(field, value);
236
520
  }
237
- #clearError() {
238
- if (this.hasErrorTarget) {
239
- this.errorTarget.setAttribute("hidden", "true");
240
- }
521
+ #markFilled(field, value) {
522
+ if (value) field.setAttribute("data-filled", "true");
523
+ else field.removeAttribute("data-filled");
241
524
  }
242
- #syncAndDispatch() {
525
+ #isWritable(field) {
526
+ return !field.disabled && !field.readOnly;
527
+ }
528
+ #isFocusable(field) {
529
+ return !field.disabled;
530
+ }
531
+ #writableAfter(field) {
243
532
  const fields = this.fieldTargets;
244
- const combinedValue = fields.map((f) => f.value).join("");
533
+ return fields.slice(fields.indexOf(field) + 1).find((next) => this.#isWritable(next)) ?? null;
534
+ }
535
+ #writableBefore(index) {
536
+ return this.#before(index).find((field) => this.#isWritable(field)) ?? null;
537
+ }
538
+ #focusableAfter(index) {
539
+ return this.fieldTargets.slice(index + 1).find((field) => this.#isFocusable(field)) ?? null;
540
+ }
541
+ #focusableBefore(index) {
542
+ return this.#before(index).find((field) => this.#isFocusable(field)) ?? null;
543
+ }
544
+ /** Fields before `index`, nearest first; empty at the first field. */
545
+ #before(index) {
546
+ return this.fieldTargets.slice(0, Math.max(index, 0)).reverse();
547
+ }
548
+ /** The event's field target, or `null` when the wiring points somewhere else. */
549
+ #fieldFrom(event) {
550
+ const input = event.currentTarget;
551
+ return this.fieldTargets.find((field) => field === input) ?? null;
552
+ }
553
+ #combinedValue() {
554
+ return this.fieldTargets.map((field) => field.value).join("");
555
+ }
556
+ /** Every field carries a character, and there is at least one field. */
557
+ #isComplete() {
558
+ const fields = this.fieldTargets;
559
+ return fields.length > 0 && fields.every((field) => field.value.length > 0);
560
+ }
561
+ /** Mirrors the combined value into the form and the root's readable state. */
562
+ #sync() {
563
+ const combined = this.#combinedValue();
245
564
  if (this.hasValueTarget) {
246
- this.valueTarget.value = combinedValue;
565
+ this.valueTarget.value = combined;
247
566
  }
248
- this.dispatch("change", { detail: { value: combinedValue } });
249
- const isCompleted = fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;
250
- if (isCompleted) {
251
- this.dispatch("complete", { detail: { value: combinedValue } });
567
+ this.#state.write(this.element, this.#stateName(combined));
568
+ return combined;
569
+ }
570
+ #stateName(combined) {
571
+ if (combined.length === 0) return "empty";
572
+ return this.#isComplete() ? "complete" : "partial";
573
+ }
574
+ #syncAndDispatch() {
575
+ const combined = this.#sync();
576
+ if (combined === this.#lastValue) return;
577
+ this.#lastValue = combined;
578
+ this.dispatch("change", { detail: { value: combined } });
579
+ if (this.#isComplete()) {
580
+ this.dispatch("complete", { detail: { value: combined } });
252
581
  }
253
582
  }
583
+ /** Surfaces rejected input on every field and on the optional error target. */
584
+ #showError() {
585
+ const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
586
+ for (const field of this.fieldTargets) {
587
+ this.#ariaInvalid.write(field, "true");
588
+ if (!errorId) continue;
589
+ this.#ariaErrorMessage.write(field, errorId);
590
+ this.#ariaDescribedBy.write(field, this.#describedByWith(field, errorId));
591
+ }
592
+ if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
593
+ this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
594
+ }
595
+ /** Returns every error lease, restoring the authored error surface. */
596
+ #clearError() {
597
+ this.#ariaInvalid.returnAll();
598
+ this.#ariaErrorMessage.returnAll();
599
+ this.#ariaDescribedBy.returnAll();
600
+ this.#errorHidden.returnAll();
601
+ }
602
+ #returnFieldLeases(field) {
603
+ this.#ariaInvalid.return(field);
604
+ this.#ariaErrorMessage.return(field);
605
+ this.#ariaDescribedBy.return(field);
606
+ }
607
+ /** The field's own description tokens with the error id appended once. */
608
+ #describedByWith(field, errorId) {
609
+ const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
610
+ return [...tokens, errorId].join(" ");
611
+ }
254
612
  };
255
613
 
256
614
  export { OtpController };