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,666 @@
1
+ /**
2
+ * digito/alpine
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * Alpine.js adapter — x-digito directive (single hidden-input architecture)
5
+ *
6
+ * Usage:
7
+ * import Alpine from 'alpinejs'
8
+ * import { DigitoAlpine } from 'digito/alpine'
9
+ * Alpine.plugin(DigitoAlpine)
10
+ * Alpine.start()
11
+ *
12
+ * <div x-digito="{ length: 6, type: 'numeric', timer: 60 }"></div>
13
+ *
14
+ * // With separator:
15
+ * <div x-digito="{ length: 6, separatorAfter: 2 }"></div>
16
+ *
17
+ * // Access public API:
18
+ * el._digito.setDisabled(true)
19
+ * el._digito.setError(true)
20
+ * el._digito.reset()
21
+ * el._digito.getCode()
22
+ *
23
+ * @author Olawale Balo — Product Designer + Design Engineer
24
+ * @license MIT
25
+ */
26
+
27
+ import {
28
+ createDigito,
29
+ createTimer,
30
+ filterString,
31
+ type DigitoOptions,
32
+ type InputType,
33
+ } from '../core/index.js'
34
+
35
+ /** Shape of the data object Alpine passes to every directive handler. */
36
+ type AlpineDirectiveData = {
37
+ /** The raw expression string from `x-digito="{ ... }"`. */
38
+ expression: string
39
+ /** Directive value segment, e.g. `x-digito:value`. Unused by Digito. */
40
+ value: string
41
+ /** Modifier segments, e.g. `x-digito.modifier`. Unused by Digito. */
42
+ modifiers: string[]
43
+ }
44
+
45
+ /** Utility functions Alpine passes to every directive handler. */
46
+ type AlpineDirectiveUtilities = {
47
+ /** Evaluate an Alpine expression in the component scope and return its value. */
48
+ evaluate: (expr: string) => unknown
49
+ /** Return a function that evaluates `expr` reactively via Alpine's effect system. */
50
+ evaluateLater: (expr: string) => (callback: (value: unknown) => void) => void
51
+ /** Register a teardown function called when the component or directive is destroyed. */
52
+ cleanup: (fn: () => void) => void
53
+ /** Run `fn` reactively; re-runs whenever its Alpine reactive dependencies change. */
54
+ effect: (fn: () => void) => void
55
+ }
56
+
57
+ /** Minimal interface for the Alpine.js instance — only the parts Digito needs. */
58
+ type AlpinePlugin = {
59
+ directive: (
60
+ name: string,
61
+ handler: (el: HTMLElement, data: AlpineDirectiveData, utilities: AlpineDirectiveUtilities) => { cleanup(): void } | void
62
+ ) => void
63
+ }
64
+
65
+ /**
66
+ * Extended options for the Alpine x-digito directive.
67
+ * Adds separator and disabled support on top of DigitoOptions.
68
+ */
69
+ type AlpineOTPOptions = DigitoOptions & {
70
+ /**
71
+ * Insert a purely visual separator after this slot index (0-based).
72
+ * Accepts a single position or an array for multiple separators.
73
+ * Default: 0 (no separator).
74
+ */
75
+ separatorAfter?: number | number[]
76
+ separator?: string
77
+ onChange?: (code: string) => void
78
+ onTick?: (remaining: number) => void
79
+ /**
80
+ * Cooldown seconds before the built-in Resend button re-enables after being clicked.
81
+ * Default: 30.
82
+ */
83
+ resendAfter?: number
84
+ /**
85
+ * When `true`, each filled slot displays a mask glyph instead of the real
86
+ * character. The hidden input switches to `type="password"`. `getCode()`
87
+ * still returns real characters. Use for PIN entry or any sensitive input flow.
88
+ * Default: `false`.
89
+ */
90
+ masked?: boolean
91
+ /**
92
+ * The glyph displayed in filled slots when `masked` is `true`.
93
+ * Default: `'●'` (U+25CF BLACK CIRCLE).
94
+ * @example maskChar: '*'
95
+ */
96
+ maskChar?: string
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // HELPERS
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ function formatCountdown(totalSeconds: number): string {
104
+ const minutes = Math.floor(totalSeconds / 60)
105
+ const seconds = totalSeconds % 60
106
+ return minutes > 0
107
+ ? `${minutes}:${String(seconds).padStart(2, '0')}`
108
+ : `0:${String(seconds).padStart(2, '0')}`
109
+ }
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // PLUGIN
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Alpine.js plugin that registers the `x-digito` directive.
117
+ *
118
+ * Install once before `Alpine.start()`:
119
+ * ```js
120
+ * import Alpine from 'alpinejs'
121
+ * import { DigitoAlpine } from 'digito/alpine'
122
+ * Alpine.plugin(DigitoAlpine)
123
+ * Alpine.start()
124
+ * ```
125
+ *
126
+ * The directive accepts the same options as `DigitoOptions` plus `separatorAfter`,
127
+ * `separator`, `masked`, and `maskChar`. Options are evaluated via Alpine's
128
+ * `evaluate()` so reactive expressions and Alpine `$data` references work.
129
+ *
130
+ * @param Alpine - The Alpine.js global passed automatically by `Alpine.plugin()`.
131
+ */
132
+ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
133
+ Alpine.directive('digito', (wrapperEl, { expression }, { evaluate }): { cleanup(): void } => {
134
+ let options: AlpineOTPOptions
135
+ try {
136
+ options = (expression ? evaluate(expression) : {}) as AlpineOTPOptions
137
+ if (!options || typeof options !== 'object') {
138
+ console.error('[x-digito] expression did not return a plain object. Got:', options)
139
+ options = {} as AlpineOTPOptions
140
+ }
141
+ } catch (err) {
142
+ console.error('[x-digito] failed to evaluate expression:', err)
143
+ options = {} as AlpineOTPOptions
144
+ }
145
+
146
+ const {
147
+ length = 6,
148
+ type = 'numeric' as InputType,
149
+ timer: timerSecs = 0,
150
+ disabled: initialDisabled = false,
151
+ onComplete,
152
+ onExpire,
153
+ onResend,
154
+ onTick: onTickProp,
155
+ haptic = true,
156
+ sound = false,
157
+ pattern,
158
+ pasteTransformer,
159
+ onInvalidChar,
160
+ onChange: onChangeProp,
161
+ onFocus: onFocusProp,
162
+ onBlur: onBlurProp,
163
+ separatorAfter: rawSepAfter = 0,
164
+ separator = '—',
165
+ resendAfter: resendCooldown = 30,
166
+ masked = false,
167
+ maskChar = '\u25CF',
168
+ autoFocus = true,
169
+ name: inputName,
170
+ placeholder = '',
171
+ selectOnFocus = false,
172
+ blurOnComplete = false,
173
+ } = options
174
+
175
+ // Normalise separatorAfter to an array for consistent rendering
176
+ const separatorAfterPositions: number[] = Array.isArray(rawSepAfter) ? rawSepAfter : [rawSepAfter]
177
+
178
+ const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
179
+
180
+ let isDisabled = initialDisabled
181
+ let successState = false
182
+
183
+ // ── Build DOM ─────────────────────────────────────────────────────────────
184
+ while (wrapperEl.firstChild) wrapperEl.removeChild(wrapperEl.firstChild)
185
+ wrapperEl.style.cssText = 'position:relative;display:inline-flex;gap:var(--digito-gap,12px);align-items:center;flex-wrap:wrap'
186
+
187
+ const slotEls: HTMLDivElement[] = []
188
+ const caretEls: HTMLDivElement[] = []
189
+
190
+ for (let i = 0; i < length; i++) {
191
+ const slotEl = document.createElement('div')
192
+ slotEl.style.cssText = [
193
+ `width:var(--digito-size,56px)`,
194
+ `height:var(--digito-size,56px)`,
195
+ `border:1px solid var(--digito-border-color,#E5E5E5)`,
196
+ `border-radius:var(--digito-radius,10px)`,
197
+ `font-size:var(--digito-font-size,24px)`,
198
+ `font-weight:600`,
199
+ `display:flex`,
200
+ `align-items:center`,
201
+ `justify-content:center`,
202
+ `background:var(--digito-bg,#FAFAFA)`,
203
+ `color:var(--digito-color,#0A0A0A)`,
204
+ `position:relative`,
205
+ `cursor:text`,
206
+ `transition:border-color 150ms ease,box-shadow 150ms ease`,
207
+ `font-family:inherit`,
208
+ `user-select:none`,
209
+ ].join(';')
210
+ slotEl.setAttribute('aria-hidden', 'true')
211
+
212
+ const caretEl = document.createElement('div')
213
+ caretEl.style.cssText = 'position:absolute;width:2px;height:52%;background:var(--digito-caret-color,#3D3D3D);border-radius:1px;animation:digito-alpine-blink 1s step-start infinite;pointer-events:none;display:none'
214
+ slotEl.appendChild(caretEl)
215
+ caretEls.push(caretEl)
216
+
217
+ slotEls.push(slotEl)
218
+ wrapperEl.appendChild(slotEl)
219
+
220
+ // Separator — purely decorative, aria-hidden, no effect on value
221
+ if (separatorAfterPositions.some(pos => pos > 0 && i === pos - 1)) {
222
+ const sepEl = document.createElement('div')
223
+ sepEl.setAttribute('aria-hidden', 'true')
224
+ sepEl.style.cssText = [
225
+ `display:flex`,
226
+ `align-items:center`,
227
+ `justify-content:center`,
228
+ `color:var(--digito-separator-color,#A1A1A1)`,
229
+ `font-size:var(--digito-separator-size,18px)`,
230
+ `font-weight:400`,
231
+ `user-select:none`,
232
+ `flex-shrink:0`,
233
+ ].join(';')
234
+ sepEl.textContent = separator
235
+ wrapperEl.appendChild(sepEl)
236
+ }
237
+ }
238
+
239
+ // Inject styles once — caret keyframes + timer/resend classes matching vanilla
240
+ if (!document.getElementById('digito-alpine-styles')) {
241
+ const s = document.createElement('style')
242
+ s.id = 'digito-alpine-styles'
243
+ s.textContent = [
244
+ '@keyframes digito-alpine-blink{0%,100%{opacity:1}50%{opacity:0}}',
245
+ '.digito-timer{display:flex;align-items:center;gap:8px;font-size:14px;padding:20px 0 0}',
246
+ '.digito-timer-label{color:var(--digito-timer-color,#5C5C5C);font-size:14px}',
247
+ '.digito-timer-badge{display:inline-flex;align-items:center;background:color-mix(in srgb,var(--digito-error-color,#FB2C36) 10%,transparent);color:var(--digito-error-color,#FB2C36);font-weight:500;font-size:14px;padding:2px 10px;border-radius:99px;height:24px}',
248
+ '.digito-resend{display:none;align-items:center;gap:8px;font-size:14px;color:var(--digito-timer-color,#5C5C5C);padding:20px 0 0}',
249
+ '.digito-resend.is-visible{display:flex}',
250
+ '.digito-resend-btn{display:inline-flex;align-items:center;background:#E8E8E8;border:none;padding:2px 10px;border-radius:99px;color:#0A0A0A;font-weight:500;font-size:14px;transition:background 150ms ease;cursor:pointer;height:24px}',
251
+ '.digito-resend-btn:hover{background:#E5E5E5}',
252
+ '.digito-resend-btn:disabled{color:#A1A1A1;cursor:not-allowed;background:#F5F5F5}',
253
+ ].join('')
254
+ document.head.appendChild(s)
255
+ }
256
+
257
+ // Hidden real input
258
+ const hiddenInputEl = document.createElement('input')
259
+ hiddenInputEl.type = masked ? 'password' : 'text'
260
+ hiddenInputEl.inputMode = type === 'numeric' ? 'numeric' : 'text'
261
+ hiddenInputEl.autocomplete = 'one-time-code'
262
+ hiddenInputEl.maxLength = length
263
+ hiddenInputEl.disabled = isDisabled
264
+ hiddenInputEl.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
265
+ hiddenInputEl.setAttribute('spellcheck', 'false')
266
+ hiddenInputEl.setAttribute('autocorrect', 'off')
267
+ hiddenInputEl.setAttribute('autocapitalize', 'off')
268
+ hiddenInputEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;border:none;outline:none;background:transparent;color:transparent;caret-color:transparent;z-index:1;cursor:text;font-size:1px'
269
+ if (inputName) hiddenInputEl.name = inputName
270
+ wrapperEl.appendChild(hiddenInputEl)
271
+
272
+ // ── Built-in timer + resend (mirrors vanilla adapter) ──────────────────────
273
+ let timerBadgeEl: HTMLSpanElement | null = null
274
+ let resendActionBtn: HTMLButtonElement | null = null
275
+ let mainCountdown: ReturnType<typeof createTimer> | null = null
276
+ let resendCountdown: ReturnType<typeof createTimer> | null = null
277
+ let builtInFooterEl: HTMLDivElement | null = null
278
+ let builtInResendRowEl: HTMLDivElement | null = null
279
+
280
+ if (timerSecs > 0) {
281
+ const shouldUseBuiltInFooter = !onTickProp
282
+
283
+ if (shouldUseBuiltInFooter) {
284
+ builtInFooterEl = document.createElement('div')
285
+ builtInFooterEl.className = 'digito-timer'
286
+
287
+ const expiresLabel = document.createElement('span')
288
+ expiresLabel.className = 'digito-timer-label'
289
+ expiresLabel.textContent = 'Code expires in'
290
+
291
+ timerBadgeEl = document.createElement('span')
292
+ timerBadgeEl.className = 'digito-timer-badge'
293
+ timerBadgeEl.textContent = formatCountdown(timerSecs)
294
+
295
+ builtInFooterEl.appendChild(expiresLabel)
296
+ builtInFooterEl.appendChild(timerBadgeEl)
297
+ wrapperEl.insertAdjacentElement('afterend', builtInFooterEl)
298
+
299
+ builtInResendRowEl = document.createElement('div')
300
+ builtInResendRowEl.className = 'digito-resend'
301
+
302
+ const didntReceiveLabel = document.createElement('span')
303
+ didntReceiveLabel.textContent = 'Didn\u2019t receive the code?'
304
+
305
+ resendActionBtn = document.createElement('button')
306
+ resendActionBtn.className = 'digito-resend-btn'
307
+ resendActionBtn.textContent = 'Resend'
308
+ resendActionBtn.type = 'button'
309
+
310
+ builtInResendRowEl.appendChild(didntReceiveLabel)
311
+ builtInResendRowEl.appendChild(resendActionBtn)
312
+ builtInFooterEl.insertAdjacentElement('afterend', builtInResendRowEl)
313
+ }
314
+
315
+ mainCountdown = createTimer({
316
+ totalSeconds: timerSecs,
317
+ onTick: (remaining) => {
318
+ if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(remaining)
319
+ onTickProp?.(remaining)
320
+ },
321
+ onExpire: () => {
322
+ if (builtInFooterEl) builtInFooterEl.style.display = 'none'
323
+ if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
324
+ onExpire?.()
325
+ },
326
+ })
327
+ mainCountdown.start()
328
+
329
+ if (shouldUseBuiltInFooter && resendActionBtn) {
330
+ resendActionBtn.addEventListener('click', () => {
331
+ if (!resendActionBtn || !timerBadgeEl || !builtInFooterEl || !builtInResendRowEl) return
332
+ builtInResendRowEl.classList.remove('is-visible')
333
+ builtInFooterEl.style.display = 'flex'
334
+ timerBadgeEl.textContent = formatCountdown(resendCooldown)
335
+ resendCountdown?.stop()
336
+ resendCountdown = createTimer({
337
+ totalSeconds: resendCooldown,
338
+ onTick: (r) => { if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(r) },
339
+ onExpire: () => {
340
+ if (builtInFooterEl) builtInFooterEl.style.display = 'none'
341
+ if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
342
+ },
343
+ })
344
+ resendCountdown.start()
345
+ onResend?.()
346
+ })
347
+ }
348
+ }
349
+
350
+ // ── DOM sync ───────────────────────────────────────────────────────────────
351
+ /**
352
+ * Reconcile every visual slot div with the current core state.
353
+ * Called after every user action (input, keydown, paste, focus, click).
354
+ *
355
+ * The Alpine adapter uses inline styles exclusively — no shared stylesheet
356
+ * is injected, so each mounted element is fully self-contained. State
357
+ * priority order for slot appearance: disabled > error > complete > active > default.
358
+ *
359
+ * Font size and color are also set inline here to support placeholder
360
+ * character styling (`--digito-placeholder-size` / `--digito-placeholder-color`),
361
+ * since CSS `:not(.is-filled)` selectors are unavailable without a stylesheet.
362
+ */
363
+ function syncSlotsToDOM(): void {
364
+ const { slotValues, activeSlot, hasError } = digito.state
365
+ const focused = document.activeElement === hiddenInputEl
366
+
367
+ slotEls.forEach((slotEl, i) => {
368
+ const char = slotValues[i] ?? ''
369
+ const isActive = i === activeSlot && focused
370
+ const isFilled = char.length === 1
371
+
372
+ let textNode = slotEl.childNodes[1] as Text | undefined
373
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
374
+ textNode = document.createTextNode('')
375
+ slotEl.appendChild(textNode)
376
+ }
377
+ textNode.nodeValue = masked && char ? maskChar : char || placeholder
378
+ slotEl.style.fontSize = isFilled
379
+ ? (masked ? 'var(--digito-masked-size,16px)' : 'var(--digito-font-size,24px)')
380
+ : 'var(--digito-placeholder-size,16px)'
381
+ slotEl.style.color = isFilled
382
+ ? 'var(--digito-color,#0A0A0A)'
383
+ : 'var(--digito-placeholder-color,#D3D3D3)'
384
+
385
+ const activeColor = 'var(--digito-active-color,#3D3D3D)'
386
+ const errorColor = 'var(--digito-error-color,#FB2C36)'
387
+ const successColor = 'var(--digito-success-color,#00C950)'
388
+
389
+ if (isDisabled) {
390
+ slotEl.style.opacity = '0.45'
391
+ slotEl.style.cursor = 'not-allowed'
392
+ slotEl.style.pointerEvents = 'none'
393
+ slotEl.style.borderColor = 'var(--digito-border-color,#E5E5E5)'
394
+ slotEl.style.boxShadow = 'none'
395
+ } else if (hasError) {
396
+ slotEl.style.opacity = ''
397
+ slotEl.style.cursor = 'text'
398
+ slotEl.style.pointerEvents = ''
399
+ slotEl.style.borderColor = errorColor
400
+ slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${errorColor} 12%,transparent)`
401
+ } else if (successState) {
402
+ slotEl.style.opacity = ''
403
+ slotEl.style.cursor = 'text'
404
+ slotEl.style.pointerEvents = ''
405
+ slotEl.style.borderColor = successColor
406
+ slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${successColor} 12%,transparent)`
407
+ } else if (isActive) {
408
+ slotEl.style.opacity = ''
409
+ slotEl.style.cursor = 'text'
410
+ slotEl.style.pointerEvents = ''
411
+ slotEl.style.borderColor = activeColor
412
+ slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${activeColor} 10%,transparent)`
413
+ slotEl.style.background = 'var(--digito-bg-filled,#FFFFFF)'
414
+ } else {
415
+ slotEl.style.opacity = ''
416
+ slotEl.style.cursor = 'text'
417
+ slotEl.style.pointerEvents = ''
418
+ slotEl.style.borderColor = 'var(--digito-border-color,#E5E5E5)'
419
+ slotEl.style.boxShadow = 'none'
420
+ slotEl.style.background = isFilled ? 'var(--digito-bg-filled,#FFFFFF)' : 'var(--digito-bg,#FAFAFA)'
421
+ }
422
+
423
+ caretEls[i].style.display = isActive && !isFilled && !isDisabled ? 'block' : 'none'
424
+ })
425
+
426
+ // Only update value when it actually differs — assigning the same string
427
+ // resets selectionStart/End in some browsers, clobbering the cursor.
428
+ const newValue = slotValues.join('')
429
+ if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
430
+ }
431
+
432
+ // ── Event handlers ─────────────────────────────────────────────────────────
433
+ hiddenInputEl.addEventListener('keydown', (e) => {
434
+ if (isDisabled) return
435
+ const pos = hiddenInputEl.selectionStart ?? 0
436
+ if (e.key === 'Backspace') {
437
+ e.preventDefault()
438
+ digito.deleteChar(pos)
439
+ syncSlotsToDOM()
440
+ onChangeProp?.(digito.getCode())
441
+ const next = digito.state.activeSlot
442
+ requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
443
+ } else if (e.key === 'ArrowLeft') {
444
+ e.preventDefault()
445
+ digito.moveFocusLeft(pos)
446
+ syncSlotsToDOM()
447
+ const next = digito.state.activeSlot
448
+ requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
449
+ } else if (e.key === 'ArrowRight') {
450
+ e.preventDefault()
451
+ digito.moveFocusRight(pos)
452
+ syncSlotsToDOM()
453
+ const next = digito.state.activeSlot
454
+ requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
455
+ } else if (e.key === 'Tab') {
456
+ if (e.shiftKey) {
457
+ if (pos === 0) return
458
+ e.preventDefault()
459
+ digito.moveFocusLeft(pos)
460
+ } else {
461
+ if (!digito.state.slotValues[pos]) return
462
+ if (pos >= length - 1) return
463
+ e.preventDefault()
464
+ digito.moveFocusRight(pos)
465
+ }
466
+ syncSlotsToDOM()
467
+ const next = digito.state.activeSlot
468
+ requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
469
+ }
470
+ })
471
+
472
+ hiddenInputEl.addEventListener('input', () => {
473
+ if (isDisabled) return
474
+ const raw = hiddenInputEl.value
475
+ if (!raw) {
476
+ digito.resetState()
477
+ hiddenInputEl.value = ''
478
+ hiddenInputEl.setSelectionRange(0, 0)
479
+ syncSlotsToDOM()
480
+ onChangeProp?.('')
481
+ return
482
+ }
483
+ const valid = filterString(raw, type, pattern).slice(0, length)
484
+ digito.resetState()
485
+ for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
486
+ const next = Math.min(valid.length, length - 1)
487
+ hiddenInputEl.value = valid
488
+ hiddenInputEl.setSelectionRange(next, next)
489
+ digito.moveFocusTo(next)
490
+ syncSlotsToDOM()
491
+ onChangeProp?.(digito.getCode())
492
+ if (blurOnComplete && digito.state.isComplete) {
493
+ requestAnimationFrame(() => hiddenInputEl.blur())
494
+ }
495
+ })
496
+
497
+ hiddenInputEl.addEventListener('paste', (e) => {
498
+ if (isDisabled) return
499
+ e.preventDefault()
500
+ const text = e.clipboardData?.getData('text') ?? ''
501
+ const pos = hiddenInputEl.selectionStart ?? 0
502
+ digito.pasteString(pos, text)
503
+ const { slotValues, activeSlot } = digito.state
504
+ hiddenInputEl.value = slotValues.join('')
505
+ hiddenInputEl.setSelectionRange(activeSlot, activeSlot)
506
+ syncSlotsToDOM()
507
+ onChangeProp?.(digito.getCode())
508
+ if (blurOnComplete && digito.state.isComplete) {
509
+ requestAnimationFrame(() => hiddenInputEl.blur())
510
+ }
511
+ })
512
+
513
+ hiddenInputEl.addEventListener('focus', () => {
514
+ onFocusProp?.()
515
+ requestAnimationFrame(() => {
516
+ const pos = digito.state.activeSlot
517
+ const char = digito.state.slotValues[pos]
518
+ if (selectOnFocus && char) {
519
+ hiddenInputEl.setSelectionRange(pos, pos + 1)
520
+ } else {
521
+ hiddenInputEl.setSelectionRange(pos, pos)
522
+ }
523
+ syncSlotsToDOM()
524
+ })
525
+ })
526
+
527
+ hiddenInputEl.addEventListener('blur', () => {
528
+ onBlurProp?.()
529
+ slotEls.forEach(s => { s.style.borderColor = 'var(--digito-border-color,#E5E5E5)'; s.style.boxShadow = 'none' })
530
+ caretEls.forEach(c => { c.style.display = 'none' })
531
+ })
532
+
533
+ hiddenInputEl.addEventListener('click', (e: MouseEvent) => {
534
+ if (isDisabled) return
535
+ // click fires after the browser places cursor (always 0 due to font-size:1px).
536
+ // Coordinate hit-test determines which slot was visually clicked, then
537
+ // setSelectionRange overrides the browser's placement.
538
+ let rawSlot = slotEls.length - 1
539
+ for (let i = 0; i < slotEls.length; i++) {
540
+ if (e.clientX <= slotEls[i].getBoundingClientRect().right) { rawSlot = i; break }
541
+ }
542
+ // Clamp to filled count so the visual active slot matches the actual cursor position.
543
+ const clickedSlot = Math.min(rawSlot, hiddenInputEl.value.length)
544
+ digito.moveFocusTo(clickedSlot)
545
+ const char = digito.state.slotValues[clickedSlot]
546
+ if (selectOnFocus && char) {
547
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot + 1)
548
+ } else {
549
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot)
550
+ }
551
+ syncSlotsToDOM()
552
+ })
553
+
554
+ requestAnimationFrame(() => {
555
+ if (!isDisabled && autoFocus) hiddenInputEl.focus()
556
+ hiddenInputEl.setSelectionRange(0, 0)
557
+ syncSlotsToDOM()
558
+ })
559
+
560
+ // ── Public API on element ──────────────────────────────────────────────────
561
+ // Exposed on `el._digito` for programmatic control from Alpine components or
562
+ // external JavaScript. Mirrors the DigitoInstance interface from the vanilla adapter.
563
+ ;(wrapperEl as HTMLElement & { _digito: unknown })._digito = {
564
+ /** Returns the current joined code string (e.g. `"123456"`). */
565
+ getCode: () => digito.getCode(),
566
+
567
+ /** Stop timers and remove built-in footer elements. Call before removing the element. */
568
+ destroy: () => {
569
+ mainCountdown?.stop()
570
+ resendCountdown?.stop()
571
+ builtInFooterEl?.remove()
572
+ builtInResendRowEl?.remove()
573
+ },
574
+
575
+ /** Clear all slots, re-focus, reset to idle state, and restart the built-in timer. */
576
+ reset: () => {
577
+ digito.resetState()
578
+ hiddenInputEl.value = ''
579
+ if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
580
+ if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
581
+ if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
582
+ resendCountdown?.stop()
583
+ mainCountdown?.restart()
584
+ if (!isDisabled) hiddenInputEl.focus()
585
+ hiddenInputEl.setSelectionRange(0, 0)
586
+ syncSlotsToDOM()
587
+ },
588
+
589
+ /** Reset and fire the `onResend` callback. */
590
+ resend: () => {
591
+ digito.resetState()
592
+ hiddenInputEl.value = ''
593
+ if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
594
+ if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
595
+ if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
596
+ resendCountdown?.stop()
597
+ mainCountdown?.restart()
598
+ if (!isDisabled) hiddenInputEl.focus()
599
+ hiddenInputEl.setSelectionRange(0, 0)
600
+ syncSlotsToDOM()
601
+ onResend?.()
602
+ },
603
+
604
+ /** Apply or clear the error state on all visual slots. */
605
+ setError: (isError: boolean) => {
606
+ if (isError) successState = false
607
+ digito.setError(isError)
608
+ syncSlotsToDOM()
609
+ },
610
+
611
+ /** Apply or clear the success state. On success, stops the timer and hides the footer. */
612
+ setSuccess: (isSuccess: boolean) => {
613
+ successState = isSuccess
614
+ if (isSuccess) {
615
+ digito.setError(false)
616
+ mainCountdown?.stop()
617
+ resendCountdown?.stop()
618
+ if (builtInFooterEl) builtInFooterEl.style.display = 'none'
619
+ if (builtInResendRowEl) builtInResendRowEl.style.display = 'none'
620
+ }
621
+ syncSlotsToDOM()
622
+ },
623
+
624
+ /**
625
+ * Enable or disable the input at runtime.
626
+ * When disabled, all keyboard input, paste events, and click-to-focus
627
+ * are silently ignored. Re-enabling automatically restores focus.
628
+ */
629
+ setDisabled: (value: boolean) => {
630
+ isDisabled = value
631
+ digito.setDisabled(value)
632
+ hiddenInputEl.disabled = value
633
+ syncSlotsToDOM()
634
+ if (!value) {
635
+ requestAnimationFrame(() => {
636
+ hiddenInputEl.focus()
637
+ hiddenInputEl.setSelectionRange(digito.state.activeSlot, digito.state.activeSlot)
638
+ })
639
+ }
640
+ },
641
+
642
+ /**
643
+ * Programmatically move focus to a specific slot index.
644
+ * Focuses the hidden input and positions the cursor at `slotIndex`.
645
+ */
646
+ focus: (slotIndex: number) => {
647
+ if (isDisabled) return
648
+ digito.moveFocusTo(slotIndex)
649
+ hiddenInputEl.focus()
650
+ hiddenInputEl.setSelectionRange(slotIndex, slotIndex)
651
+ syncSlotsToDOM()
652
+ },
653
+ }
654
+
655
+ return {
656
+ /** Alpine calls this when the component is destroyed. Stops timers and removes footer elements. */
657
+ cleanup() {
658
+ mainCountdown?.stop()
659
+ resendCountdown?.stop()
660
+ builtInFooterEl?.remove()
661
+ builtInResendRowEl?.remove()
662
+ digito.resetState()
663
+ },
664
+ }
665
+ })
666
+ }