digitojs 1.2.1 → 1.2.4

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