digitojs 1.0.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 (73) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +753 -0
  4. package/dist/adapters/alpine.d.ts +71 -0
  5. package/dist/adapters/alpine.d.ts.map +1 -0
  6. package/dist/adapters/alpine.js +560 -0
  7. package/dist/adapters/alpine.js.map +1 -0
  8. package/dist/adapters/react.d.ts +223 -0
  9. package/dist/adapters/react.d.ts.map +1 -0
  10. package/dist/adapters/react.js +337 -0
  11. package/dist/adapters/react.js.map +1 -0
  12. package/dist/adapters/svelte.d.ts +139 -0
  13. package/dist/adapters/svelte.d.ts.map +1 -0
  14. package/dist/adapters/svelte.js +295 -0
  15. package/dist/adapters/svelte.js.map +1 -0
  16. package/dist/adapters/vanilla.d.ts +110 -0
  17. package/dist/adapters/vanilla.d.ts.map +1 -0
  18. package/dist/adapters/vanilla.js +650 -0
  19. package/dist/adapters/vanilla.js.map +1 -0
  20. package/dist/adapters/vue.d.ts +163 -0
  21. package/dist/adapters/vue.d.ts.map +1 -0
  22. package/dist/adapters/vue.js +298 -0
  23. package/dist/adapters/vue.js.map +1 -0
  24. package/dist/adapters/web-component.d.ts +192 -0
  25. package/dist/adapters/web-component.d.ts.map +1 -0
  26. package/dist/adapters/web-component.js +832 -0
  27. package/dist/adapters/web-component.js.map +1 -0
  28. package/dist/core/feedback.d.ts +26 -0
  29. package/dist/core/feedback.d.ts.map +1 -0
  30. package/dist/core/feedback.js +47 -0
  31. package/dist/core/feedback.js.map +1 -0
  32. package/dist/core/filter.d.ts +24 -0
  33. package/dist/core/filter.d.ts.map +1 -0
  34. package/dist/core/filter.js +47 -0
  35. package/dist/core/filter.js.map +1 -0
  36. package/dist/core/index.d.ts +16 -0
  37. package/dist/core/index.d.ts.map +1 -0
  38. package/dist/core/index.js +15 -0
  39. package/dist/core/index.js.map +1 -0
  40. package/dist/core/machine.d.ts +67 -0
  41. package/dist/core/machine.d.ts.map +1 -0
  42. package/dist/core/machine.js +328 -0
  43. package/dist/core/machine.js.map +1 -0
  44. package/dist/core/timer.d.ts +24 -0
  45. package/dist/core/timer.d.ts.map +1 -0
  46. package/dist/core/timer.js +67 -0
  47. package/dist/core/timer.js.map +1 -0
  48. package/dist/core/types.d.ts +162 -0
  49. package/dist/core/types.d.ts.map +1 -0
  50. package/dist/core/types.js +10 -0
  51. package/dist/core/types.js.map +1 -0
  52. package/dist/digito-wc.min.js +254 -0
  53. package/dist/digito-wc.min.js.map +7 -0
  54. package/dist/digito.min.js +91 -0
  55. package/dist/digito.min.js.map +7 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +25 -0
  59. package/dist/index.js.map +1 -0
  60. package/package.json +109 -0
  61. package/src/adapters/alpine.ts +666 -0
  62. package/src/adapters/react.tsx +603 -0
  63. package/src/adapters/svelte.ts +444 -0
  64. package/src/adapters/vanilla.ts +810 -0
  65. package/src/adapters/vue.ts +462 -0
  66. package/src/adapters/web-component.ts +858 -0
  67. package/src/core/feedback.ts +44 -0
  68. package/src/core/filter.ts +48 -0
  69. package/src/core/index.ts +16 -0
  70. package/src/core/machine.ts +373 -0
  71. package/src/core/timer.ts +75 -0
  72. package/src/core/types.ts +167 -0
  73. package/src/index.ts +51 -0
@@ -0,0 +1,858 @@
1
+ /**
2
+ * digito/web-component
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * Framework-agnostic Web Component — <digito-input>
5
+ * Uses single hidden-input architecture for correct SMS autofill + a11y.
6
+ *
7
+ * Attributes:
8
+ * length Number of slots (default: 6)
9
+ * type Character set: numeric | alphabet | alphanumeric | any (default: numeric)
10
+ * timer Countdown seconds (default: 0 = no timer)
11
+ * resend-after Cooldown seconds before the built-in Resend re-enables (default: 30)
12
+ * disabled Boolean attribute — disables all input when present
13
+ * separator-after Slot index (1-based) or comma-separated list, e.g. "3" or "2,4" (default: none)
14
+ * separator Separator character to render (default: —)
15
+ * name Sets the hidden input's name attr for native form submission
16
+ * placeholder Character shown in empty slots (e.g. "○" or "_")
17
+ * auto-focus Boolean attribute — focus input on mount (default: true when absent)
18
+ * select-on-focus Boolean attribute — selects the current slot char on focus
19
+ * blur-on-complete Boolean attribute — blurs the input when all slots are filled
20
+ *
21
+ * Events:
22
+ * complete CustomEvent<{ code: string }> — fired when all slots filled
23
+ * expire CustomEvent — fired when timer reaches zero
24
+ * change CustomEvent<{ code: string }> — fired on every input change
25
+ *
26
+ * DOM API:
27
+ * el.reset()
28
+ * el.setError(boolean)
29
+ * el.setSuccess(boolean)
30
+ * el.setDisabled(boolean)
31
+ * el.getCode() -> string
32
+ * el.pattern = /^[0-9A-F]$/ (JS property, not attribute)
33
+ * el.pasteTransformer = fn (JS property)
34
+ * el.onComplete = code => {} (JS property)
35
+ * el.onResend = () => {} (JS property)
36
+ * el.onFocus = () => {} (JS property)
37
+ * el.onBlur = () => {} (JS property)
38
+ * el.onInvalidChar = (char, i) => {} (JS property)
39
+ *
40
+ * @author Olawale Balo — Product Designer + Design Engineer
41
+ * @license MIT
42
+ */
43
+
44
+ import {
45
+ createDigito,
46
+ createTimer,
47
+ filterString,
48
+ type InputType,
49
+ } from '../core/index.js'
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // SHADOW DOM STYLES
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ const STYLES = `
56
+ :host {
57
+ display: inline-block;
58
+ position: relative;
59
+ line-height: 1;
60
+ }
61
+
62
+ .digito-wc-root {
63
+ position: relative;
64
+ display: inline-block;
65
+ }
66
+
67
+ .digito-wc-slots {
68
+ display: inline-flex;
69
+ gap: var(--digito-gap, 12px);
70
+ align-items: center;
71
+ position: relative;
72
+ }
73
+
74
+ .digito-wc-hidden {
75
+ position: absolute;
76
+ inset: 0;
77
+ width: 100%;
78
+ height: 100%;
79
+ opacity: 0;
80
+ border: none;
81
+ outline: none;
82
+ background: transparent;
83
+ color: transparent;
84
+ caret-color: transparent;
85
+ z-index: 1;
86
+ cursor: text;
87
+ font-size: 1px;
88
+ }
89
+
90
+ .digito-wc-slot {
91
+ position: relative;
92
+ width: var(--digito-size, 56px);
93
+ height: var(--digito-size, 56px);
94
+ border: 1px solid var(--digito-border-color, #E5E5E5);
95
+ border-radius: var(--digito-radius, 10px);
96
+ font-size: var(--digito-font-size, 24px);
97
+ font-weight: 600;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ background: var(--digito-bg, #FAFAFA);
102
+ color: var(--digito-color, #0A0A0A);
103
+ font-family: inherit;
104
+ cursor: text;
105
+ user-select: none;
106
+ transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease;
107
+ }
108
+ .digito-wc-slot.is-active {
109
+ border-color: var(--digito-active-color, #3D3D3D);
110
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--digito-active-color, #3D3D3D) 10%, transparent);
111
+ background: var(--digito-bg-filled, #FFFFFF);
112
+ }
113
+ .digito-wc-slot.is-filled { background: var(--digito-bg-filled, #FFFFFF); }
114
+ .digito-wc-slot.is-error {
115
+ border-color: var(--digito-error-color, #FB2C36);
116
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--digito-error-color, #FB2C36) 12%, transparent);
117
+ }
118
+ .digito-wc-slot.is-success {
119
+ border-color: var(--digito-success-color, #00C950);
120
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--digito-success-color, #00C950) 12%, transparent);
121
+ }
122
+ .digito-wc-slot.is-disabled {
123
+ opacity: 0.45;
124
+ cursor: not-allowed;
125
+ pointer-events: none;
126
+ }
127
+ .digito-wc-slot:not(.is-filled) {
128
+ font-size: var(--digito-placeholder-size, 16px);
129
+ color: var(--digito-placeholder-color, #D3D3D3);
130
+ }
131
+ .digito-wc-slot.is-masked.is-filled {
132
+ font-size: var(--digito-masked-size, 16px);
133
+ }
134
+
135
+ .digito-wc-separator {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ color: var(--digito-separator-color, #A1A1A1);
140
+ font-size: var(--digito-separator-size, 18px);
141
+ font-weight: 400;
142
+ user-select: none;
143
+ flex-shrink: 0;
144
+ padding: 0 2px;
145
+ }
146
+
147
+ .digito-wc-caret {
148
+ position: absolute;
149
+ width: 2px;
150
+ height: 52%;
151
+ background: var(--digito-caret-color, #3D3D3D);
152
+ border-radius: 1px;
153
+ animation: wc-blink 1s step-start infinite;
154
+ pointer-events: none;
155
+ display: none;
156
+ }
157
+ @keyframes wc-blink { 0%,100%{opacity:1} 50%{opacity:0} }
158
+
159
+ .digito-wc-timer {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 8px;
163
+ font-size: 14px;
164
+ padding: 12px 0 0;
165
+ }
166
+ .digito-wc-timer-label {
167
+ color: var(--digito-timer-color, #5C5C5C);
168
+ font-size: 14px;
169
+ }
170
+ .digito-wc-timer-badge {
171
+ display: inline-flex;
172
+ align-items: center;
173
+ background: color-mix(in srgb, var(--digito-error-color, #FB2C36) 10%, transparent);
174
+ color: var(--digito-error-color, #FB2C36);
175
+ font-weight: 500;
176
+ font-size: 14px;
177
+ padding: 2px 10px;
178
+ border-radius: 99px;
179
+ height: 24px;
180
+ font-variant-numeric: tabular-nums;
181
+ }
182
+
183
+ .digito-wc-resend {
184
+ display: none;
185
+ align-items: center;
186
+ gap: 8px;
187
+ font-size: 14px;
188
+ color: var(--digito-timer-color, #5C5C5C);
189
+ padding: 12px 0 0;
190
+ }
191
+ .digito-wc-resend.is-visible { display: flex; }
192
+ .digito-wc-resend-btn {
193
+ display: inline-flex;
194
+ align-items: center;
195
+ background: #E8E8E8;
196
+ border: none;
197
+ padding: 2px 10px;
198
+ border-radius: 99px;
199
+ color: #0A0A0A;
200
+ font-weight: 500;
201
+ font-size: 14px;
202
+ transition: background 150ms ease;
203
+ cursor: pointer;
204
+ height: 24px;
205
+ font-family: inherit;
206
+ }
207
+ .digito-wc-resend-btn:hover { background: #E5E5E5; }
208
+ .digito-wc-resend-btn:disabled { color: #A1A1A1; cursor: not-allowed; background: #F5F5F5; }
209
+ `
210
+
211
+ // ─────────────────────────────────────────────────────────────────────────────
212
+ // HELPERS
213
+ // ─────────────────────────────────────────────────────────────────────────────
214
+
215
+ function formatCountdown(totalSeconds: number): string {
216
+ const m = Math.floor(totalSeconds / 60)
217
+ const s = totalSeconds % 60
218
+ return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`
219
+ }
220
+
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+ // WEB COMPONENT
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+
225
+ class DigitoInput extends HTMLElement {
226
+ /**
227
+ * HTML attribute names whose changes trigger `attributeChangedCallback`.
228
+ * Any change to these attributes causes a full shadow DOM rebuild so the
229
+ * component always reflects its attribute state without manual reconciliation.
230
+ */
231
+ static observedAttributes = ['length', 'type', 'timer', 'resend-after', 'disabled', 'separator-after', 'separator', 'masked', 'mask-char', 'name', 'placeholder', 'auto-focus', 'select-on-focus', 'blur-on-complete']
232
+
233
+ // Shadow DOM references — rebuilt in full on every attributeChangedCallback.
234
+ private slotEls: HTMLDivElement[] = []
235
+ private caretEls: HTMLDivElement[] = []
236
+ private hiddenInput: HTMLInputElement | null = null
237
+ private timerEl: HTMLDivElement | null = null
238
+ private timerBadgeEl: HTMLSpanElement | null = null
239
+ private resendEl: HTMLDivElement | null = null
240
+ private timerCtrl: ReturnType<typeof createTimer> | null = null
241
+ private resendCountdown: ReturnType<typeof createTimer> | null = null
242
+ private digito: ReturnType<typeof createDigito> | null = null
243
+ private shadow: ShadowRoot
244
+
245
+ // Runtime mutable state — toggled by setDisabled() without a full rebuild.
246
+ private _isDisabled = false
247
+ private _isSuccess = false
248
+
249
+ // JS-property-only options. These cannot be expressed as HTML attributes
250
+ // (RegExp and functions are not serialisable to strings), so they are stored
251
+ // here and applied on every build().
252
+ private _pattern: RegExp | undefined = undefined
253
+ private _pasteTransformer: ((raw: string) => string) | undefined = undefined
254
+ private _onComplete: ((code: string) => void) | undefined = undefined
255
+ private _onResend: (() => void) | undefined = undefined
256
+ private _onFocus: (() => void) | undefined = undefined
257
+ private _onBlur: (() => void) | undefined = undefined
258
+ private _onInvalidChar: ((char: string, index: number) => void) | undefined = undefined
259
+
260
+ /** Called when all slots are filled. Also dispatches the `complete` CustomEvent. */
261
+ set onComplete(fn: ((code: string) => void) | undefined) {
262
+ if (fn !== undefined && typeof fn !== 'function') {
263
+ console.warn('[digito] onComplete must be a function, got:', typeof fn); return
264
+ }
265
+ this._onComplete = fn
266
+ }
267
+ /** Called when the built-in Resend button is clicked. */
268
+ set onResend(fn: (() => void) | undefined) {
269
+ if (fn !== undefined && typeof fn !== 'function') {
270
+ console.warn('[digito] onResend must be a function, got:', typeof fn); return
271
+ }
272
+ this._onResend = fn
273
+ }
274
+ /** Fires when the hidden input receives focus. Set as JS property. */
275
+ set onFocus(fn: (() => void) | undefined) {
276
+ if (fn !== undefined && typeof fn !== 'function') {
277
+ console.warn('[digito] onFocus must be a function, got:', typeof fn); return
278
+ }
279
+ this._onFocus = fn
280
+ }
281
+ /** Fires when the hidden input loses focus. Set as JS property. */
282
+ set onBlur(fn: (() => void) | undefined) {
283
+ if (fn !== undefined && typeof fn !== 'function') {
284
+ console.warn('[digito] onBlur must be a function, got:', typeof fn); return
285
+ }
286
+ this._onBlur = fn
287
+ }
288
+ /**
289
+ * Fires when a typed character is rejected by type/pattern validation.
290
+ * Receives the character and the slot index it was attempted on.
291
+ * Set as JS property.
292
+ */
293
+ set onInvalidChar(fn: ((char: string, index: number) => void) | undefined) {
294
+ if (fn !== undefined && typeof fn !== 'function') {
295
+ console.warn('[digito] onInvalidChar must be a function, got:', typeof fn); return
296
+ }
297
+ this._onInvalidChar = fn
298
+ if (this.shadow.children.length > 0) this.build()
299
+ }
300
+
301
+ /**
302
+ * Arbitrary per-character regex. When set, each typed/pasted character must
303
+ * match to be accepted. Takes precedence over the type attribute for
304
+ * character validation. Cannot be expressed as an HTML attribute — set as a
305
+ * JS property instead.
306
+ * @example el.pattern = /^[0-9A-F]$/
307
+ */
308
+ set pattern(re: RegExp | undefined) {
309
+ if (re !== undefined && !(re instanceof RegExp)) {
310
+ console.warn('[digito] pattern must be a RegExp, got:', typeof re); return
311
+ }
312
+ this._pattern = re
313
+ if (this.shadow.children.length > 0) this.build()
314
+ }
315
+
316
+ /**
317
+ * Optional paste transformer function. Applied to raw clipboard text before
318
+ * filtering. Use to strip formatting (e.g. `"G-123456"` → `"123456"`).
319
+ * Cannot be expressed as an HTML attribute — set as a JS property.
320
+ * @example el.pasteTransformer = (raw) => raw.replace(/\s+|-/g, '')
321
+ */
322
+ set pasteTransformer(fn: ((raw: string) => string) | undefined) {
323
+ if (fn !== undefined && typeof fn !== 'function') {
324
+ console.warn('[digito] pasteTransformer must be a function, got:', typeof fn); return
325
+ }
326
+ this._pasteTransformer = fn
327
+ if (this.shadow.children.length > 0) this.build()
328
+ }
329
+
330
+ constructor() {
331
+ super()
332
+ // Open shadow root so external CSS custom properties (--digito-*) cascade in.
333
+ this.shadow = this.attachShadow({ mode: 'open' })
334
+ }
335
+
336
+ /** Called when the element is inserted into the DOM. Triggers the initial build. */
337
+ connectedCallback(): void { this.build() }
338
+
339
+ /**
340
+ * Called when the element is removed from the DOM.
341
+ * Stops both timers and cancels any pending `onComplete` timeout to avoid
342
+ * callbacks firing after the element is detached.
343
+ */
344
+ disconnectedCallback(): void {
345
+ this.timerCtrl?.stop()
346
+ this.resendCountdown?.stop()
347
+ this.digito?.resetState()
348
+ }
349
+
350
+ /**
351
+ * Called when any observed attribute changes after the initial connection.
352
+ * Guards on `shadow.children.length > 0` so it does not fire before
353
+ * `connectedCallback` has completed the first build.
354
+ */
355
+ attributeChangedCallback(): void {
356
+ if (this.shadow.children.length > 0) this.build()
357
+ }
358
+
359
+ // ── Attribute accessors ─────────────────────────────────────────────────────
360
+ // Each getter reads directly from the live attribute to stay in sync with
361
+ // external attribute mutations. All values are snapshotted at the top of
362
+ // build() so a single rebuild is always internally consistent.
363
+
364
+ private get _length(): number {
365
+ const v = parseInt(this.getAttribute('length') ?? '6', 10)
366
+ return isNaN(v) || v < 1 ? 6 : Math.floor(v)
367
+ }
368
+ private get _type(): InputType { return (this.getAttribute('type') ?? 'numeric') as InputType }
369
+ private get _timer(): number {
370
+ const v = parseInt(this.getAttribute('timer') ?? '0', 10)
371
+ return isNaN(v) || v < 0 ? 0 : Math.floor(v)
372
+ }
373
+ private get _resendAfter(): number {
374
+ const v = parseInt(this.getAttribute('resend-after') ?? '30', 10)
375
+ return isNaN(v) || v < 1 ? 30 : Math.floor(v)
376
+ }
377
+ private get _disabledAttr(): boolean { return this.hasAttribute('disabled') }
378
+ /** Parses `separator-after="2,4"` into `[2, 4]`. Filters NaN and zero values. */
379
+ private get _separatorAfter(): number[] {
380
+ const v = this.getAttribute('separator-after')
381
+ if (!v) return []
382
+ return v.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
383
+ }
384
+ private get _separator(): string { return this.getAttribute('separator') ?? '—' }
385
+ /** `masked` is a boolean attribute — present means true, absent means false. */
386
+ private get _masked(): boolean { return this.hasAttribute('masked') }
387
+ private get _maskChar(): string { return this.getAttribute('mask-char') ?? '\u25CF' }
388
+ private get _name(): string { return this.getAttribute('name') ?? '' }
389
+ private get _placeholder(): string { return this.getAttribute('placeholder') ?? '' }
390
+ /**
391
+ * `auto-focus` defaults to `true` when the attribute is absent.
392
+ * Setting `auto-focus="false"` explicitly suppresses focus on mount.
393
+ */
394
+ private get _autoFocus(): boolean { return !this.hasAttribute('auto-focus') || this.getAttribute('auto-focus') !== 'false' }
395
+ private get _selectOnFocus(): boolean { return this.hasAttribute('select-on-focus') }
396
+ private get _blurOnComplete(): boolean { return this.hasAttribute('blur-on-complete') }
397
+
398
+ // ── Build ───────────────────────────────────────────────────────────────────
399
+ /**
400
+ * Constructs the entire shadow DOM from scratch.
401
+ *
402
+ * Called on first connect, on every observed attribute change, and when
403
+ * certain JS-property setters (`pattern`, `pasteTransformer`, `onInvalidChar`)
404
+ * are assigned after mount. Tears down any running timer and resets the
405
+ * state machine before rebuilding to prevent duplicate intervals or stale
406
+ * closure references from the previous build.
407
+ */
408
+ private build(): void {
409
+ const length = this._length
410
+ const type = this._type
411
+ const timerSecs = this._timer
412
+ const resendCooldown = this._resendAfter
413
+ const separatorPositions = this._separatorAfter
414
+ const separator = this._separator
415
+ const masked = this._masked
416
+ const inputName = this._name
417
+ const autoFocus = this._autoFocus
418
+ const selectOnFocus = this._selectOnFocus
419
+ const blurOnComplete = this._blurOnComplete
420
+ this._isDisabled = this._disabledAttr
421
+
422
+ this.timerCtrl?.stop()
423
+ this.resendCountdown?.stop()
424
+ this.digito?.resetState()
425
+
426
+ // Clear shadow DOM using safe child removal
427
+ while (this.shadow.firstChild) this.shadow.removeChild(this.shadow.firstChild)
428
+ this.slotEls = []
429
+ this.caretEls = []
430
+ this.timerEl = null
431
+ this.timerBadgeEl = null
432
+ this.resendEl = null
433
+ this.timerCtrl = null
434
+ this.resendCountdown = null
435
+
436
+ // Styles
437
+ const styleEl = document.createElement('style')
438
+ styleEl.textContent = STYLES
439
+ this.shadow.appendChild(styleEl)
440
+
441
+ // Root
442
+ const rootEl = document.createElement('div')
443
+ rootEl.className = 'digito-wc-root'
444
+
445
+ // Slot row
446
+ const slotRowEl = document.createElement('div')
447
+ slotRowEl.className = 'digito-wc-slots'
448
+
449
+ // Visual slots + optional separator
450
+ for (let i = 0; i < length; i++) {
451
+ const slotEl = document.createElement('div')
452
+ slotEl.className = 'digito-wc-slot'
453
+ slotEl.setAttribute('aria-hidden', 'true')
454
+
455
+ const caretEl = document.createElement('div')
456
+ caretEl.className = 'digito-wc-caret'
457
+ slotEl.appendChild(caretEl)
458
+
459
+ this.caretEls.push(caretEl)
460
+ this.slotEls.push(slotEl)
461
+ slotRowEl.appendChild(slotEl)
462
+
463
+ if (separatorPositions.some(pos => i === pos - 1)) {
464
+ const sepEl = document.createElement('div')
465
+ sepEl.className = 'digito-wc-separator'
466
+ sepEl.textContent = separator
467
+ sepEl.setAttribute('aria-hidden', 'true')
468
+ slotRowEl.appendChild(sepEl)
469
+ }
470
+ }
471
+
472
+ // Hidden input
473
+ const hiddenInput = document.createElement('input')
474
+ hiddenInput.type = masked ? 'password' : 'text'
475
+ hiddenInput.inputMode = type === 'numeric' ? 'numeric' : 'text'
476
+ hiddenInput.autocomplete = 'one-time-code'
477
+ hiddenInput.maxLength = length
478
+ hiddenInput.disabled = this._isDisabled
479
+ hiddenInput.className = 'digito-wc-hidden'
480
+ hiddenInput.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
481
+ hiddenInput.setAttribute('spellcheck', 'false')
482
+ hiddenInput.setAttribute('autocorrect', 'off')
483
+ hiddenInput.setAttribute('autocapitalize', 'off')
484
+ if (inputName) hiddenInput.name = inputName
485
+ this.hiddenInput = hiddenInput
486
+
487
+ rootEl.appendChild(slotRowEl)
488
+ rootEl.appendChild(hiddenInput)
489
+ this.shadow.appendChild(rootEl)
490
+
491
+ // Core
492
+ this.digito = createDigito({
493
+ length,
494
+ type,
495
+ pattern: this._pattern,
496
+ pasteTransformer: this._pasteTransformer,
497
+ onInvalidChar: this._onInvalidChar,
498
+ onComplete: (code) => {
499
+ // Call JS property setter AND dispatch CustomEvent
500
+ this._onComplete?.(code)
501
+ this.dispatchEvent(
502
+ new CustomEvent('complete', { detail: { code }, bubbles: true, composed: true })
503
+ )
504
+ },
505
+ })
506
+
507
+ // ── Built-in timer + resend (mirrors vanilla/alpine adapters) ──────────────
508
+ if (timerSecs > 0) {
509
+ // Timer footer — "Code expires in [0:45]"
510
+ const timerFooterEl = document.createElement('div')
511
+ timerFooterEl.className = 'digito-wc-timer'
512
+ this.timerEl = timerFooterEl
513
+
514
+ const timerLabel = document.createElement('span')
515
+ timerLabel.className = 'digito-wc-timer-label'
516
+ timerLabel.textContent = 'Code expires in'
517
+
518
+ const timerBadge = document.createElement('span')
519
+ timerBadge.className = 'digito-wc-timer-badge'
520
+ timerBadge.textContent = formatCountdown(timerSecs)
521
+ this.timerBadgeEl = timerBadge
522
+
523
+ timerFooterEl.appendChild(timerLabel)
524
+ timerFooterEl.appendChild(timerBadge)
525
+ rootEl.appendChild(timerFooterEl)
526
+
527
+ // Resend row — "Didn't receive the code? [Resend]"
528
+ const resendRowEl = document.createElement('div')
529
+ resendRowEl.className = 'digito-wc-resend'
530
+ this.resendEl = resendRowEl
531
+
532
+ const resendLabel = document.createElement('span')
533
+ resendLabel.textContent = 'Didn\u2019t receive the code?'
534
+
535
+ const resendBtn = document.createElement('button')
536
+ resendBtn.className = 'digito-wc-resend-btn'
537
+ resendBtn.textContent = 'Resend'
538
+ resendBtn.type = 'button'
539
+
540
+ resendRowEl.appendChild(resendLabel)
541
+ resendRowEl.appendChild(resendBtn)
542
+ rootEl.appendChild(resendRowEl)
543
+
544
+ // Main countdown
545
+ this.timerCtrl = createTimer({
546
+ totalSeconds: timerSecs,
547
+ onTick: (r) => { if (this.timerBadgeEl) this.timerBadgeEl.textContent = formatCountdown(r) },
548
+ onExpire: () => {
549
+ if (this.timerEl) this.timerEl.style.display = 'none'
550
+ if (this.resendEl) this.resendEl.classList.add('is-visible')
551
+ this.dispatchEvent(new CustomEvent('expire', { bubbles: true, composed: true }))
552
+ },
553
+ })
554
+ this.timerCtrl.start()
555
+
556
+ // Resend button click — restart with resend cooldown
557
+ resendBtn.addEventListener('click', () => {
558
+ if (!this.timerEl || !this.timerBadgeEl || !this.resendEl) return
559
+ this.resendEl.classList.remove('is-visible')
560
+ this.timerEl.style.display = 'flex'
561
+ this.timerBadgeEl.textContent = formatCountdown(resendCooldown)
562
+ this.resendCountdown?.stop()
563
+ this.resendCountdown = createTimer({
564
+ totalSeconds: resendCooldown,
565
+ onTick: (r) => { if (this.timerBadgeEl) this.timerBadgeEl.textContent = formatCountdown(r) },
566
+ onExpire: () => {
567
+ if (this.timerEl) this.timerEl.style.display = 'none'
568
+ if (this.resendEl) this.resendEl.classList.add('is-visible')
569
+ },
570
+ })
571
+ this.resendCountdown.start()
572
+ this._onResend?.()
573
+ })
574
+ }
575
+
576
+ this.attachEvents(selectOnFocus, blurOnComplete)
577
+
578
+ if (this._isDisabled) this.applyDisabledDOM(true)
579
+
580
+ hiddenInput.addEventListener('click', (e: MouseEvent) => {
581
+ if (this._isDisabled) return
582
+ // click fires after the browser places cursor (always 0 due to font-size:1px).
583
+ // Coordinate hit-test determines which slot was visually clicked, then
584
+ // setSelectionRange overrides the browser's placement.
585
+ let rawSlot = this.slotEls.length - 1
586
+ for (let i = 0; i < this.slotEls.length; i++) {
587
+ if (e.clientX <= this.slotEls[i].getBoundingClientRect().right) { rawSlot = i; break }
588
+ }
589
+ // Clamp to filled count so the visual active slot matches the actual cursor position.
590
+ const clickedSlot = Math.min(rawSlot, hiddenInput.value.length)
591
+ this.digito?.moveFocusTo(clickedSlot)
592
+ const char = this.digito?.state.slotValues[clickedSlot] ?? ''
593
+ if (selectOnFocus && char) {
594
+ hiddenInput.setSelectionRange(clickedSlot, clickedSlot + 1)
595
+ } else {
596
+ hiddenInput.setSelectionRange(clickedSlot, clickedSlot)
597
+ }
598
+ this.syncSlotsToDOM()
599
+ })
600
+
601
+ requestAnimationFrame(() => {
602
+ if (!this._isDisabled && autoFocus) hiddenInput.focus()
603
+ hiddenInput.setSelectionRange(0, 0)
604
+ this.syncSlotsToDOM()
605
+ })
606
+ }
607
+
608
+ // ── DOM sync ────────────────────────────────────────────────────────────────
609
+ /**
610
+ * Reconcile the shadow slot divs with the current core state using CSS class
611
+ * toggles. Called after every user action (input, keydown, paste, focus, click).
612
+ *
613
+ * Uses `this.shadow.activeElement` instead of `document.activeElement` to
614
+ * correctly detect focus within the shadow root across all browsers — the
615
+ * document active element is the host `<digito-input>` element, not the
616
+ * internal hidden input.
617
+ */
618
+ private syncSlotsToDOM(): void {
619
+ if (!this.digito || !this.hiddenInput) return
620
+ const { slotValues, activeSlot, hasError } = this.digito.state
621
+ const focused = this.shadow.activeElement === this.hiddenInput
622
+
623
+ this.slotEls.forEach((slotEl, i) => {
624
+ const char = slotValues[i] ?? ''
625
+ const isActive = i === activeSlot && focused
626
+
627
+ let textNode = slotEl.childNodes[1] as Text | undefined
628
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
629
+ textNode = document.createTextNode('')
630
+ slotEl.appendChild(textNode)
631
+ }
632
+ textNode.nodeValue = this._masked && char ? this._maskChar : char || this._placeholder
633
+
634
+ slotEl.classList.toggle('is-active', isActive && !this._isDisabled)
635
+ slotEl.classList.toggle('is-filled', !!char)
636
+ slotEl.classList.toggle('is-masked', this._masked)
637
+ slotEl.classList.toggle('is-error', hasError)
638
+ slotEl.classList.toggle('is-success', this._isSuccess)
639
+ slotEl.classList.toggle('is-disabled', this._isDisabled)
640
+
641
+ this.caretEls[i].style.display = isActive && !char && !this._isDisabled ? 'block' : 'none'
642
+ })
643
+
644
+ // Only update value when it actually differs — assigning the same string
645
+ // resets selectionStart/End in some browsers, clobbering the cursor.
646
+ const newValue = slotValues.join('')
647
+ if (this.hiddenInput.value !== newValue) this.hiddenInput.value = newValue
648
+ }
649
+
650
+ /**
651
+ * Apply or remove the disabled state directly on existing DOM nodes without
652
+ * triggering a full rebuild. Used by both `build()` (initial disabled attr)
653
+ * and `setDisabled()` (runtime toggle).
654
+ */
655
+ private applyDisabledDOM(value: boolean): void {
656
+ if (this.hiddenInput) this.hiddenInput.disabled = value
657
+ this.slotEls.forEach(s => s.classList.toggle('is-disabled', value))
658
+ }
659
+
660
+ // ── Events ──────────────────────────────────────────────────────────────────
661
+ /**
662
+ * Wire all event listeners to the hidden input element.
663
+ * Called once at the end of each `build()`. Because `build()` creates a fresh
664
+ * `hiddenInput` element, there is no need to `removeEventListener` — the old
665
+ * element is discarded and its listeners are garbage-collected with it.
666
+ *
667
+ * @param selectOnFocus When `true`, focusing a filled slot selects its character.
668
+ * @param blurOnComplete When `true`, blurs the input after the last slot is filled.
669
+ */
670
+ private attachEvents(selectOnFocus: boolean, blurOnComplete: boolean): void {
671
+ const input = this.hiddenInput!
672
+ const digito = this.digito!
673
+ const length = this._length
674
+ const type = this._type
675
+ const pattern = this._pattern
676
+
677
+ input.addEventListener('keydown', (e) => {
678
+ if (this._isDisabled) return
679
+ const pos = input.selectionStart ?? 0
680
+ if (e.key === 'Backspace') {
681
+ e.preventDefault()
682
+ digito.deleteChar(pos)
683
+ this.syncSlotsToDOM()
684
+ this.dispatchChange()
685
+ const next = digito.state.activeSlot
686
+ requestAnimationFrame(() => input.setSelectionRange(next, next))
687
+ } else if (e.key === 'ArrowLeft') {
688
+ e.preventDefault()
689
+ digito.moveFocusLeft(pos)
690
+ this.syncSlotsToDOM()
691
+ requestAnimationFrame(() => input.setSelectionRange(digito.state.activeSlot, digito.state.activeSlot))
692
+ } else if (e.key === 'ArrowRight') {
693
+ e.preventDefault()
694
+ digito.moveFocusRight(pos)
695
+ this.syncSlotsToDOM()
696
+ requestAnimationFrame(() => input.setSelectionRange(digito.state.activeSlot, digito.state.activeSlot))
697
+ } else if (e.key === 'Tab') {
698
+ if (e.shiftKey) {
699
+ if (pos === 0) return
700
+ e.preventDefault()
701
+ digito.moveFocusLeft(pos)
702
+ } else {
703
+ if (!digito.state.slotValues[pos]) return
704
+ if (pos >= length - 1) return
705
+ e.preventDefault()
706
+ digito.moveFocusRight(pos)
707
+ }
708
+ this.syncSlotsToDOM()
709
+ const next = digito.state.activeSlot
710
+ requestAnimationFrame(() => input.setSelectionRange(next, next))
711
+ }
712
+ })
713
+
714
+ input.addEventListener('input', () => {
715
+ if (this._isDisabled) return
716
+ const raw = input.value
717
+ if (!raw) {
718
+ digito.resetState()
719
+ input.value = ''
720
+ input.setSelectionRange(0, 0)
721
+ this.syncSlotsToDOM()
722
+ this.dispatchChange()
723
+ return
724
+ }
725
+ const valid = filterString(raw, type, pattern).slice(0, length)
726
+ digito.resetState()
727
+ for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
728
+ const next = Math.min(valid.length, length - 1)
729
+ input.value = valid
730
+ input.setSelectionRange(next, next)
731
+ digito.moveFocusTo(next)
732
+ this.syncSlotsToDOM()
733
+ this.dispatchChange()
734
+ if (blurOnComplete && digito.state.isComplete) {
735
+ requestAnimationFrame(() => input.blur())
736
+ }
737
+ })
738
+
739
+ input.addEventListener('paste', (e) => {
740
+ if (this._isDisabled) return
741
+ e.preventDefault()
742
+ const text = e.clipboardData?.getData('text') ?? ''
743
+ const pos = input.selectionStart ?? 0
744
+ digito.pasteString(pos, text)
745
+ const { slotValues, activeSlot } = digito.state
746
+ input.value = slotValues.join('')
747
+ input.setSelectionRange(activeSlot, activeSlot)
748
+ this.syncSlotsToDOM()
749
+ this.dispatchChange()
750
+ if (blurOnComplete && digito.state.isComplete) {
751
+ requestAnimationFrame(() => input.blur())
752
+ }
753
+ })
754
+
755
+ input.addEventListener('focus', () => {
756
+ this._onFocus?.()
757
+ requestAnimationFrame(() => {
758
+ const pos = digito.state.activeSlot
759
+ const char = digito.state.slotValues[pos]
760
+ if (selectOnFocus && char) {
761
+ input.setSelectionRange(pos, pos + 1)
762
+ } else {
763
+ input.setSelectionRange(pos, pos)
764
+ }
765
+ this.syncSlotsToDOM()
766
+ })
767
+ })
768
+
769
+ input.addEventListener('blur', () => {
770
+ this._onBlur?.()
771
+ this.slotEls.forEach(s => { s.classList.remove('is-active') })
772
+ this.caretEls.forEach(c => { c.style.display = 'none' })
773
+ })
774
+
775
+ }
776
+
777
+ /**
778
+ * Dispatch a `change` CustomEvent carrying the current code string.
779
+ * Fired after every input, paste, and backspace action.
780
+ * `composed: true` lets the event cross the shadow root boundary so host-page
781
+ * listeners registered with `el.addEventListener('change', ...)` receive it.
782
+ */
783
+ private dispatchChange(): void {
784
+ this.dispatchEvent(new CustomEvent('change', {
785
+ detail: { code: this.digito?.getCode() ?? '' },
786
+ bubbles: true,
787
+ composed: true,
788
+ }))
789
+ }
790
+
791
+ // ── Public DOM API ──────────────────────────────────────────────────────────
792
+
793
+ /** Clear all slots, reset the timer display, and re-focus the hidden input. */
794
+ reset(): void {
795
+ this._isSuccess = false
796
+ this.digito?.resetState()
797
+ if (this.hiddenInput) {
798
+ this.hiddenInput.value = ''
799
+ if (!this._isDisabled) this.hiddenInput.focus()
800
+ this.hiddenInput.setSelectionRange(0, 0)
801
+ }
802
+ if (this.timerBadgeEl) this.timerBadgeEl.textContent = formatCountdown(this._timer)
803
+ if (this.timerEl) this.timerEl.style.display = 'flex'
804
+ if (this.resendEl) this.resendEl.classList.remove('is-visible')
805
+ this.resendCountdown?.stop()
806
+ this.timerCtrl?.restart()
807
+ this.syncSlotsToDOM()
808
+ }
809
+
810
+ /** Apply or clear the error state on all visual slots. */
811
+ setError(isError: boolean): void {
812
+ if (isError) this._isSuccess = false
813
+ this.digito?.setError(isError)
814
+ this.syncSlotsToDOM()
815
+ }
816
+
817
+ /** Apply or clear the success state on all visual slots. Stops the timer on success. */
818
+ setSuccess(isSuccess: boolean): void {
819
+ this._isSuccess = isSuccess
820
+ if (isSuccess) {
821
+ this.digito?.setError(false)
822
+ this.timerCtrl?.stop()
823
+ this.resendCountdown?.stop()
824
+ if (this.timerEl) this.timerEl.style.display = 'none'
825
+ if (this.resendEl) this.resendEl.style.display = 'none'
826
+ }
827
+ this.syncSlotsToDOM()
828
+ }
829
+
830
+ /**
831
+ * Enable or disable the input at runtime.
832
+ * Equivalent to toggling the `disabled` HTML attribute but without triggering
833
+ * a full rebuild. Re-enabling automatically restores focus to the active slot.
834
+ */
835
+ setDisabled(value: boolean): void {
836
+ this._isDisabled = value
837
+ this.digito?.setDisabled(value)
838
+ this.applyDisabledDOM(value)
839
+ this.syncSlotsToDOM()
840
+ if (!value && this.hiddenInput) {
841
+ requestAnimationFrame(() => {
842
+ this.hiddenInput?.focus()
843
+ this.hiddenInput?.setSelectionRange(this.digito?.state.activeSlot ?? 0, this.digito?.state.activeSlot ?? 0)
844
+ })
845
+ }
846
+ }
847
+
848
+ /** Returns the current code as a joined string (e.g. `"123456"`). */
849
+ getCode(): string {
850
+ return this.digito?.getCode() ?? ''
851
+ }
852
+ }
853
+
854
+ if (typeof customElements !== 'undefined' && !customElements.get('digito-input')) {
855
+ customElements.define('digito-input', DigitoInput)
856
+ }
857
+
858
+ export { DigitoInput }