stimeo-ui 0.7.0 → 0.9.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,264 @@ 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
+ }
214
+ function statesDiffer(left, right) {
215
+ return left.value !== right.value || left.state !== right.state;
216
+ }
69
217
  var OtpController = class extends Controller {
70
218
  static targets = ["field", "value", "error"];
71
219
  static values = {
72
- length: { type: Number, default: 6 },
73
- pattern: { type: String, default: "[0-9]" }
220
+ pattern: { type: String, default: DEFAULT_PATTERN }
74
221
  };
75
- static actions = ["onInput", "onKeydown", "onPaste"];
76
- static events = ["change", "complete", "invalid"];
222
+ static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
223
+ static events = ["change", "complete", "invalid", "reconcile"];
224
+ /** Validated matcher; the hot path never compiles a raw declaration. */
225
+ #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
226
+ /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
227
+ #patternSource = DEFAULT_PATTERN;
228
+ /** Public state carried by the last dispatch; keeps a no-op sync silent. */
229
+ #published = null;
230
+ /** Field whose confirming `input` after `compositionend` is already handled. */
231
+ #confirmedField = null;
232
+ /** True between connect and disconnect, so pre-connect Value changes stay silent. */
233
+ #connected = false;
234
+ /** Digit each field last committed, restored when rejected input replaced it. */
235
+ #committed = /* @__PURE__ */ new WeakMap();
236
+ /** Collapses one batch of field target callbacks into a single reconciliation. */
237
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
238
+ #ariaInvalid = new AttributeLease("aria-invalid");
239
+ #ariaErrorMessage = new AttributeLease("aria-errormessage");
240
+ #ariaDescribedBy = new AttributeLease("aria-describedby");
241
+ #errorHidden = new AttributeLease("hidden");
242
+ #state = new AttributeLease("data-state");
243
+ /** Rewinds the transient error surface before Turbo freezes the page. */
244
+ #beforeCache = new BeforeCacheReset(() => this.#clearError());
77
245
  /** Owns IME lifecycle state across every digit field. */
78
246
  #composition = new CompositionTracker({
247
+ onStart: () => {
248
+ this.#confirmedField = null;
249
+ },
79
250
  onEnd: (event) => {
80
- const input = event.currentTarget;
81
- if (input) this.#handleInputValidation(input);
251
+ const input = this.#fieldFrom(event);
252
+ if (!input) return;
253
+ this.#confirmedField = input;
254
+ const committed = event.data ?? "";
255
+ this.#accept(input, committed.length > input.value.length ? committed : input.value);
82
256
  }
83
257
  });
84
258
  connect() {
85
- for (const field of this.fieldTargets) {
86
- field.addEventListener("focus", this.#onFieldFocus);
87
- this.#composition.observe(field);
88
- }
259
+ this.#connected = true;
260
+ for (const field of this.fieldTargets) this.#bind(field);
261
+ document.addEventListener("reset", this.#onReset, true);
262
+ this.#beforeCache.activate();
263
+ this.#reconcile.activate();
264
+ this.#adopt();
265
+ this.#sync();
89
266
  }
90
267
  disconnect() {
91
- for (const field of this.fieldTargets) {
92
- field.removeEventListener("focus", this.#onFieldFocus);
93
- }
268
+ this.#connected = false;
269
+ for (const field of this.fieldTargets) this.#unbind(field);
94
270
  this.#composition.disconnect();
271
+ document.removeEventListener("reset", this.#onReset, true);
272
+ this.#beforeCache.deactivate();
273
+ this.#reconcile.cancel();
274
+ this.#clearError();
275
+ this.#state.return(this.element);
95
276
  }
96
277
  /**
97
278
  * Stimulus lifecycle callback when a new field target enters the DOM.
98
- * Ensures new additions are also wired with overwriting support.
279
+ * Wires the new field and folds the wider digit count into one reconciliation,
280
+ * which stays inert until `connect()` opens the window.
99
281
  */
100
282
  fieldTargetConnected(element) {
101
- element.addEventListener("focus", this.#onFieldFocus);
102
- this.#composition.observe(element);
283
+ this.#bind(element);
284
+ this.#adoptField(element);
285
+ this.#reconcile.schedule();
103
286
  }
104
- /** Removes focus listeners when fields are dropped. */
287
+ /** Releases a dropped field's listeners and leases, then reconciles the rest. */
105
288
  fieldTargetDisconnected(element) {
106
- element.removeEventListener("focus", this.#onFieldFocus);
107
- this.#composition.unobserve(element);
289
+ this.#unbind(element);
290
+ this.#returnFieldLeases(element);
291
+ this.#reconcile.schedule();
292
+ }
293
+ /**
294
+ * Re-validates a changed `pattern` declaration once and drops any entered digit
295
+ * the new pattern no longer accepts, so the combined value stays interpretable.
296
+ */
297
+ patternValueChanged() {
298
+ const compiled = compilePattern(this.patternValue);
299
+ this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
300
+ this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
301
+ if (!this.#connected) return;
302
+ let dropped = false;
303
+ for (const field of this.fieldTargets) {
304
+ if (field.value === "" || !this.#isWritable(field)) continue;
305
+ if (this.#pattern.test(field.value)) continue;
306
+ this.#writeField(field, "");
307
+ dropped = true;
308
+ }
309
+ if (dropped) this.#syncAndDispatch();
108
310
  }
109
- /** Handles keystroke inputs and advances focus to the next field. */
311
+ /** Handles keystroke inputs, distributes autofilled text, and advances focus. */
110
312
  onInput(event) {
111
- const input = event.currentTarget;
313
+ const input = this.#fieldFrom(event);
112
314
  if (!input) return;
315
+ const confirmed = this.#confirmedField;
316
+ this.#confirmedField = null;
317
+ if (confirmed === input) return;
113
318
  if (this.#composition.isComposing(event)) return;
114
- this.#handleInputValidation(input);
319
+ this.#accept(input);
115
320
  }
116
- /** Handles Backspace retreating, arrows, and home/end navigation. */
321
+ /** Handles Backspace clearing, arrows, and home/end navigation. */
117
322
  onKeydown(event) {
118
323
  if (isReservedArrowChord(event)) return;
119
- const input = event.currentTarget;
324
+ const input = this.#fieldFrom(event);
120
325
  if (!input) return;
121
- const index = this.fieldTargets.indexOf(input);
122
- if (index === -1) return;
326
+ this.#confirmedField = null;
123
327
  if (this.#composition.isComposing(event)) return;
328
+ const index = this.fieldTargets.indexOf(input);
329
+ const fields = this.fieldTargets;
124
330
  switch (logicalArrowKey(event.key, this.element)) {
125
331
  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");
332
+ if (hasModifier(event)) break;
333
+ if (input.value && this.#isWritable(input)) {
334
+ event.preventDefault();
335
+ this.#writeField(input, "");
141
336
  this.#clearError();
142
337
  this.#syncAndDispatch();
338
+ } else {
339
+ const previous = this.#writableBefore(index);
340
+ if (previous) {
341
+ event.preventDefault();
342
+ this.#writeField(previous, "");
343
+ previous.focus();
344
+ this.#clearError();
345
+ this.#syncAndDispatch();
346
+ }
143
347
  }
144
348
  break;
145
- case "ArrowLeft":
146
- if (index > 0) {
349
+ case "ArrowLeft": {
350
+ const previous = this.#focusableBefore(index);
351
+ if (previous) {
147
352
  event.preventDefault();
148
- this.fieldTargets[index - 1]?.focus();
353
+ previous.focus();
149
354
  }
150
355
  break;
151
- case "ArrowRight":
152
- if (index < this.lengthValue - 1) {
356
+ }
357
+ case "ArrowRight": {
358
+ const next = this.#focusableAfter(index);
359
+ if (next) {
153
360
  event.preventDefault();
154
- this.fieldTargets[index + 1]?.focus();
361
+ next.focus();
155
362
  }
156
363
  break;
157
- case "Home":
158
- event.preventDefault();
159
- this.fieldTargets[0]?.focus();
364
+ }
365
+ case "Home": {
366
+ if (hasModifier(event)) break;
367
+ const first = fields.find((field) => this.#isFocusable(field));
368
+ if (first) {
369
+ event.preventDefault();
370
+ first.focus();
371
+ }
160
372
  break;
161
- case "End":
162
- event.preventDefault();
163
- this.fieldTargets[this.lengthValue - 1]?.focus();
373
+ }
374
+ case "End": {
375
+ if (hasModifier(event)) break;
376
+ const last = fields.slice().reverse().find((field) => this.#isFocusable(field));
377
+ if (last) {
378
+ event.preventDefault();
379
+ last.focus();
380
+ }
164
381
  break;
382
+ }
165
383
  }
166
384
  }
167
- /** Divides pasted string characters across available input fields. */
385
+ /** Divides pasted string characters across the available input fields. */
168
386
  onPaste(event) {
169
- const input = event.currentTarget;
387
+ const input = this.#fieldFrom(event);
170
388
  if (!input) return;
171
- const startIndex = this.fieldTargets.indexOf(input);
172
- if (startIndex === -1) return;
173
389
  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
- }
390
+ this.#confirmedField = null;
391
+ this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
392
+ }
393
+ /**
394
+ * Redirects a pointer landing on an empty field to the earliest empty one, so
395
+ * a passcode is entered in order. Filled fields stay directly reachable for
396
+ * correction, and keyboard focus is left alone.
397
+ */
398
+ onPointerDown(event) {
399
+ const input = this.#fieldFrom(event);
400
+ if (input?.value !== "") return;
401
+ const first = this.fieldTargets.find((field) => this.#isWritable(field) && field.value === "");
402
+ if (!first || first === input) return;
403
+ event.preventDefault();
404
+ first.focus();
405
+ }
406
+ /** Empties every writable field and restarts entry at the first of them. */
407
+ clear() {
408
+ for (const field of this.fieldTargets) {
409
+ if (this.#isWritable(field)) this.#writeField(field, "");
193
410
  }
194
411
  this.#clearError();
195
- const focusTargetIndex = lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;
196
- this.fieldTargets[focusTargetIndex]?.focus();
412
+ this.fieldTargets.find((field) => this.#isWritable(field))?.focus();
197
413
  this.#syncAndDispatch();
198
414
  }
199
415
  #onFieldFocus = (event) => {
@@ -202,55 +418,210 @@ var OtpController = class extends Controller {
202
418
  input.select();
203
419
  }
204
420
  };
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");
421
+ /** Reconciles derived state after a non-cancelled reset restores the fields. */
422
+ #onReset = (event) => {
423
+ const form = event.target;
424
+ if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
425
+ queueMicrotask(() => {
426
+ if (event.defaultPrevented) return;
427
+ this.#adopt();
428
+ this.#syncAndDispatch();
429
+ });
430
+ };
431
+ /** Whether a form owns at least one field or the hidden combined value. */
432
+ #ownedBy(form) {
433
+ if (this.fieldTargets.some((field) => field.form === form)) return true;
434
+ return this.hasValueTarget && this.valueTarget.form === form;
435
+ }
436
+ #bind(field) {
437
+ field.addEventListener("focus", this.#onFieldFocus);
438
+ this.#composition.observe(field);
439
+ }
440
+ #unbind(field) {
441
+ field.removeEventListener("focus", this.#onFieldFocus);
442
+ this.#composition.unobserve(field);
443
+ if (this.#confirmedField === field) this.#confirmedField = null;
444
+ }
445
+ /** Reads every field back so a restored or reset group starts consistent. */
446
+ #adopt() {
447
+ for (const field of this.fieldTargets) this.#adoptField(field);
448
+ this.#clearError();
449
+ if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
450
+ }
451
+ /** Takes one field's current value as the truth behind its derived state. */
452
+ #adoptField(field) {
453
+ this.#committed.set(field, field.value);
454
+ this.#markFilled(field, field.value);
455
+ }
456
+ /**
457
+ * Absorbs a batch of field additions or removals as one state transition.
458
+ *
459
+ * The page, not the user, moved the state here, so it is reported as
460
+ * `reconcile`: automation listening for `change` must not read a re-render as
461
+ * an edit, and a passcode that happens to end up full must not fire the
462
+ * `complete` that submits it.
463
+ *
464
+ * Completeness moves on its own when the field count changes: dropping a
465
+ * trailing empty field completes a passcode whose combined value never moved,
466
+ * and adding one un-completes it. Comparing the whole derived state, not the
467
+ * string it contains, is what makes those transitions reportable.
468
+ */
469
+ #reconcileFields() {
470
+ const previous = this.#published;
471
+ const current = this.#sync();
472
+ if (previous && !statesDiffer(previous, current)) return;
473
+ this.dispatch("reconcile", { detail: { value: current.value } });
474
+ }
475
+ /**
476
+ * Validates the text an entry point received and distributes what it accepts.
477
+ * `text` defaults to the field's own value; a confirmation passes the string it
478
+ * committed, which `maxlength` would otherwise have truncated.
479
+ */
480
+ #accept(input, text = input.value) {
481
+ const raw = toHalfWidth(text);
482
+ if (raw === "") {
483
+ this.#writeField(input, "");
214
484
  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");
485
+ this.#syncAndDispatch();
486
+ return;
487
+ }
488
+ this.#distribute(input, raw);
489
+ }
490
+ /**
491
+ * Fills `text`'s accepted characters into the writable fields at and after the
492
+ * entry point, then leaves focus on the field after the last one filled.
493
+ */
494
+ #distribute(from, text) {
495
+ const accepted = Array.from(text).filter((char) => this.#pattern.test(char));
496
+ let reached = false;
497
+ const slots = this.fieldTargets.filter((field) => {
498
+ reached ||= field === from;
499
+ return reached && this.#isWritable(field);
500
+ });
501
+ const filled = Math.min(accepted.length, slots.length);
502
+ if (filled === 0) {
503
+ this.#restore(from);
222
504
  this.#showError();
223
- } else {
224
- input.removeAttribute("data-filled");
505
+ this.#sync();
506
+ return;
507
+ }
508
+ for (let i = 0; i < filled; i++) {
509
+ const field = slots[i];
510
+ const char = accepted[i];
511
+ if (field && char) this.#writeField(field, char);
225
512
  }
513
+ const last = slots[filled - 1];
514
+ if (last) (this.#writableAfter(last) ?? last).focus();
515
+ this.#clearError();
226
516
  this.#syncAndDispatch();
227
517
  }
228
- #normalizeValue(val) {
229
- return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
518
+ /** Restores the digit a field committed before rejected input replaced it. */
519
+ #restore(field) {
520
+ this.#writeField(field, this.#committed.get(field) ?? "");
230
521
  }
231
- #showError() {
232
- if (this.hasErrorTarget) {
233
- this.errorTarget.removeAttribute("hidden");
234
- }
235
- this.dispatch("invalid", { detail: { pattern: this.patternValue } });
522
+ /** Commits one field's value and the derived hook that reports it as entered. */
523
+ #writeField(field, value) {
524
+ field.value = value;
525
+ this.#committed.set(field, value);
526
+ this.#markFilled(field, value);
236
527
  }
237
- #clearError() {
238
- if (this.hasErrorTarget) {
239
- this.errorTarget.setAttribute("hidden", "true");
240
- }
528
+ #markFilled(field, value) {
529
+ if (value) field.setAttribute("data-filled", "true");
530
+ else field.removeAttribute("data-filled");
241
531
  }
242
- #syncAndDispatch() {
532
+ #isWritable(field) {
533
+ return !field.disabled && !field.readOnly;
534
+ }
535
+ #isFocusable(field) {
536
+ return !field.disabled;
537
+ }
538
+ #writableAfter(field) {
243
539
  const fields = this.fieldTargets;
244
- const combinedValue = fields.map((f) => f.value).join("");
540
+ return fields.slice(fields.indexOf(field) + 1).find((next) => this.#isWritable(next)) ?? null;
541
+ }
542
+ #writableBefore(index) {
543
+ return this.#before(index).find((field) => this.#isWritable(field)) ?? null;
544
+ }
545
+ #focusableAfter(index) {
546
+ return this.fieldTargets.slice(index + 1).find((field) => this.#isFocusable(field)) ?? null;
547
+ }
548
+ #focusableBefore(index) {
549
+ return this.#before(index).find((field) => this.#isFocusable(field)) ?? null;
550
+ }
551
+ /** Fields before `index`, nearest first; empty at the first field. */
552
+ #before(index) {
553
+ return this.fieldTargets.slice(0, Math.max(index, 0)).reverse();
554
+ }
555
+ /** The event's field target, or `null` when the wiring points somewhere else. */
556
+ #fieldFrom(event) {
557
+ const input = event.currentTarget;
558
+ return this.fieldTargets.find((field) => field === input) ?? null;
559
+ }
560
+ #combinedValue() {
561
+ return this.fieldTargets.map((field) => field.value).join("");
562
+ }
563
+ /** Every field carries a character, and there is at least one field. */
564
+ #isComplete() {
565
+ const fields = this.fieldTargets;
566
+ return fields.length > 0 && fields.every((field) => field.value.length > 0);
567
+ }
568
+ /**
569
+ * Mirrors the combined value into the form and the root's readable state, and
570
+ * records what was published so the next pass can compare against it.
571
+ */
572
+ #sync() {
573
+ const combined = this.#combinedValue();
245
574
  if (this.hasValueTarget) {
246
- this.valueTarget.value = combinedValue;
575
+ this.valueTarget.value = combined;
247
576
  }
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 } });
577
+ const state = this.#stateName(combined);
578
+ this.#state.write(this.element, state);
579
+ const published = { value: combined, state };
580
+ this.#published = published;
581
+ return published;
582
+ }
583
+ #stateName(combined) {
584
+ if (combined.length === 0) return "empty";
585
+ return this.#isComplete() ? "complete" : "partial";
586
+ }
587
+ #syncAndDispatch() {
588
+ const previous = this.#published;
589
+ const { value: combined } = this.#sync();
590
+ if (previous?.value === combined) return;
591
+ this.dispatch("change", { detail: { value: combined } });
592
+ if (this.#isComplete()) {
593
+ this.dispatch("complete", { detail: { value: combined } });
252
594
  }
253
595
  }
596
+ /** Surfaces rejected input on every field and on the optional error target. */
597
+ #showError() {
598
+ const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
599
+ for (const field of this.fieldTargets) {
600
+ this.#ariaInvalid.write(field, "true");
601
+ if (!errorId) continue;
602
+ this.#ariaErrorMessage.write(field, errorId);
603
+ this.#ariaDescribedBy.write(field, this.#describedByWith(field, errorId));
604
+ }
605
+ if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
606
+ this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
607
+ }
608
+ /** Returns every error lease, restoring the authored error surface. */
609
+ #clearError() {
610
+ this.#ariaInvalid.returnAll();
611
+ this.#ariaErrorMessage.returnAll();
612
+ this.#ariaDescribedBy.returnAll();
613
+ this.#errorHidden.returnAll();
614
+ }
615
+ #returnFieldLeases(field) {
616
+ this.#ariaInvalid.return(field);
617
+ this.#ariaErrorMessage.return(field);
618
+ this.#ariaDescribedBy.return(field);
619
+ }
620
+ /** The field's own description tokens with the error id appended once. */
621
+ #describedByWith(field, errorId) {
622
+ const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
623
+ return [...tokens, errorId].join(" ");
624
+ }
254
625
  };
255
626
 
256
627
  export { OtpController };