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,810 @@
1
+ /**
2
+ * digito/vanilla
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * DOM adapter using the single-hidden-input architecture.
5
+ *
6
+ * Architecture:
7
+ * One real <input> sits invisibly behind the visual slot divs.
8
+ * It captures ALL keyboard input, paste, and native SMS autofill.
9
+ * The visual slot <div>s are pure mirrors — they display characters
10
+ * from the hidden input's value, show a fake caret on the active slot,
11
+ * and forward click events to focus the real input.
12
+ *
13
+ * Why this is better than multiple inputs:
14
+ * - autocomplete="one-time-code" works as native single-input autofill
15
+ * - iOS SMS autofill works without any hacks
16
+ * - Screen readers see one real input — perfect a11y
17
+ * - No focus-juggling between inputs on every keystroke
18
+ * - Password managers can't confuse the slots for separate fields
19
+ *
20
+ * Web OTP API:
21
+ * When supported (Android Chrome), navigator.credentials.get is called
22
+ * automatically to intercept the SMS OTP code without any user interaction.
23
+ * The AbortController is wired to destroy() so the request is cancelled
24
+ * on cleanup. Falls back gracefully in all other environments.
25
+ *
26
+ * Two timer modes:
27
+ * Built-in UI — omit onTick. Digito renders "Code expires in [0:60]"
28
+ * and "Didn't receive the code? Resend" automatically.
29
+ * Custom UI — pass onTick. Digito fires the callback and skips its timer.
30
+ *
31
+ * @author Olawale Balo — Product Designer + Design Engineer
32
+ * @license MIT
33
+ */
34
+
35
+ import {
36
+ createDigito,
37
+ createTimer,
38
+ filterString,
39
+ type DigitoOptions,
40
+ type InputType,
41
+ } from '../core/index.js'
42
+
43
+ export { createTimer } from '../core/index.js'
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // PUBLIC TYPES
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ /** The control surface returned by initDigito for each mounted wrapper. */
50
+ export type DigitoInstance = {
51
+ /** Clear all slots, restart timer, focus the hidden input. */
52
+ reset: () => void
53
+ /** Reset + fire onResend callback. */
54
+ resend: () => void
55
+ /** Apply or clear the error state on all visual slots. */
56
+ setError: (isError: boolean) => void
57
+ /** Apply or clear the success state on all visual slots. */
58
+ setSuccess: (isSuccess: boolean) => void
59
+ /**
60
+ * Enable or disable the input. When disabled, all keypresses and pastes are
61
+ * silently ignored and the hidden input is set to disabled. Use during async
62
+ * verification to prevent the user from modifying the code mid-request.
63
+ */
64
+ setDisabled: (isDisabled: boolean) => void
65
+ /** Returns the current joined code string. */
66
+ getCode: () => string
67
+ /** Programmatically move focus to a slot index (focuses the hidden input). */
68
+ focus: (slotIndex: number) => void
69
+ /** Remove all event listeners and stop the timer. */
70
+ destroy: () => void
71
+ }
72
+
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // STYLES
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ const INJECTED_STYLE_ID = 'digito-styles'
79
+
80
+ /**
81
+ * Injects the digito stylesheet into head exactly once per page.
82
+ *
83
+ * CSS custom properties (set on .digito-wrapper to override):
84
+ * --digito-size Slot width + height (default: 56px)
85
+ * --digito-gap Gap between slots (default: 12px)
86
+ * --digito-radius Slot border radius (default: 10px)
87
+ * --digito-font-size Digit font size (default: 24px)
88
+ * --digito-bg Slot background (default: #FAFAFA)
89
+ * --digito-bg-filled Slot background when filled (default: #FFFFFF)
90
+ * --digito-color Digit text colour (default: #0A0A0A)
91
+ * --digito-border-color Default slot border (default: #E5E5E5)
92
+ * --digito-active-color Active slot border + ring (default: #757575)
93
+ * --digito-error-color Error border, ring + badge (default: #FB2C36)
94
+ * --digito-success-color Success border + ring (default: #00C950)
95
+ * --digito-timer-color Timer label text colour (default: #5C5C5C)
96
+ * --digito-caret-color Fake caret colour (default: #3D3D3D)
97
+ * --digito-separator-color Separator text colour (default: #A1A1A1)
98
+ * --digito-separator-size Separator font size (default: 18px)
99
+ * --digito-masked-size Mask character font size (default: 16px)
100
+ */
101
+ function injectStylesOnce(): void {
102
+ if (typeof document === 'undefined') return
103
+ if (document.getElementById(INJECTED_STYLE_ID)) return
104
+
105
+ const styleEl = document.createElement('style')
106
+ styleEl.id = INJECTED_STYLE_ID
107
+ styleEl.textContent = [
108
+ '.digito-element{position:relative;display:inline-block;line-height:1}',
109
+ '.digito-hidden-input{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}',
110
+ '.digito-content{display:inline-flex;gap:var(--digito-gap,12px);align-items:center;padding:24px 0 0px;position:relative}',
111
+ '.digito-slot{position:relative;width:var(--digito-size,56px);height:var(--digito-size,56px);border:1px solid var(--digito-border-color,#E5E5E5);border-radius:var(--digito-radius,10px);font-size:var(--digito-font-size,24px);font-weight:600;display:flex;align-items:center;justify-content:center;background:var(--digito-bg,#FAFAFA);color:var(--digito-color,#0A0A0A);transition:border-color 150ms ease,box-shadow 150ms ease,background 150ms ease;user-select:none;-webkit-user-select:none;cursor:text;font-family:inherit}',
112
+ '.digito-slot.is-active{border-color:var(--digito-active-color,#3D3D3D);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-active-color,#3D3D3D) 10%,transparent);background:var(--digito-bg-filled,#FFFFFF)}',
113
+ '.digito-slot.is-filled{background:var(--digito-bg-filled,#FFFFFF)}',
114
+ '.digito-slot.is-error{border-color:var(--digito-error-color,#FB2C36);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-error-color,#FB2C36) 12%,transparent)}',
115
+ '.digito-slot.is-success{border-color:var(--digito-success-color,#00C950);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-success-color,#00C950) 12%,transparent)}',
116
+ '.digito-slot.is-disabled{opacity:0.45;cursor:not-allowed;pointer-events:none}',
117
+ '.digito-caret{position:absolute;width:2px;height:52%;background:var(--digito-caret-color,#3D3D3D);border-radius:1px;animation:digito-blink 1s step-start infinite;pointer-events:none}',
118
+ '@keyframes digito-blink{0%,100%{opacity:1}50%{opacity:0}}',
119
+ '.digito-separator{display:flex;align-items:center;justify-content:center;color:var(--digito-separator-color,#A1A1A1);font-size:var(--digito-separator-size,18px);font-weight:400;user-select:none;flex-shrink:0;}',
120
+ '.digito-slot:not(.is-filled){font-size:var(--digito-placeholder-size,16px);color:var(--digito-placeholder-color,#D3D3D3)}',
121
+ '.digito-slot.is-masked.is-filled{font-size:var(--digito-masked-size,16px)}',
122
+ '.digito-timer{display:flex;align-items:center;gap:8px;font-size:14px;padding:20px 0 0}',
123
+ '.digito-timer-label{color:var(--digito-timer-color,#5C5C5C);font-size:14px}',
124
+ '.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}',
125
+ '.digito-resend{display:none;align-items:center;gap:8px;font-size:14px;color:var(--digito-timer-color,#5C5C5C);padding:20px 0 0}',
126
+ '.digito-resend.is-visible{display:flex}',
127
+ '.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}',
128
+ '.digito-resend-btn:hover{background:#E5E5E5}',
129
+ '.digito-resend-btn:disabled{color:#A1A1A1;cursor:not-allowed;background:#F5F5F5}',
130
+ ].join('')
131
+ document.head.appendChild(styleEl)
132
+ }
133
+
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+ // INIT
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Vanilla-only options that extend the core DigitoOptions.
141
+ * These are not part of the shared adapter API.
142
+ */
143
+ export type VanillaOnlyOptions = Partial<DigitoOptions> & {
144
+ /**
145
+ * Insert a purely visual separator after this slot index (0-based).
146
+ * The separator is aria-hidden, never enters the value, and has no effect on state.
147
+ * Accepts a single position or an array for multiple separators.
148
+ * Default: 0 (no separator).
149
+ * @example separatorAfter: 3 -> [ ][ ][ ] — [ ][ ][ ] (6-slot field)
150
+ * @example separatorAfter: [2, 4] -> [ ][ ] — [ ][ ] — [ ][ ]
151
+ */
152
+ separatorAfter?: number | number[]
153
+ /**
154
+ * The character or string to render as the separator.
155
+ * Default: '—'
156
+ */
157
+ separator?: string
158
+ /**
159
+ * When `true`, each filled slot displays a mask glyph instead of the real
160
+ * character. The hidden input switches to `type="password"` so the OS keyboard
161
+ * and browser autocomplete treat it as a sensitive field.
162
+ *
163
+ * `getCode()` and `onComplete` always return the real characters — masking is
164
+ * purely visual. Use for PIN entry or any flow where the value should not be
165
+ * visible to shoulder-surfers.
166
+ *
167
+ * Default: `false`.
168
+ */
169
+ masked?: boolean
170
+ /**
171
+ * The glyph displayed in filled slots when `masked` is `true`.
172
+ * Allows substituting the default bullet with any character of your choice
173
+ * (e.g. `'*'`, `'•'`, `'x'`).
174
+ *
175
+ * Readable via the `data-mask-char` HTML attribute:
176
+ * `<div class="digito-wrapper" data-mask-char="*">`.
177
+ *
178
+ * Default: `'●'` (U+25CF BLACK CIRCLE).
179
+ */
180
+ maskChar?: string
181
+ }
182
+
183
+ /**
184
+ * Mount digito on one or more wrapper elements.
185
+ *
186
+ * @param target CSS selector or HTMLElement. Default: '.digito-wrapper'
187
+ * @param options Runtime options — supplement or override data attributes.
188
+ * @returns Array of DigitoInstance objects, one per wrapper found.
189
+ */
190
+ export function initDigito(
191
+ target: string | HTMLElement = '.digito-wrapper',
192
+ options: VanillaOnlyOptions = {},
193
+ ): DigitoInstance[] {
194
+ injectStylesOnce()
195
+
196
+ const wrapperElements: HTMLElement[] = typeof target === 'string'
197
+ ? Array.from(document.querySelectorAll<HTMLElement>(target))
198
+ : [target]
199
+
200
+ return wrapperElements.map(wrapperEl => mountOnWrapper(wrapperEl, options))
201
+ }
202
+
203
+
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // MOUNT
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ // Web OTP API — the spec adds OTPCredential to the Credential type but it is
209
+ // not yet in TypeScript's standard DOM lib. Declare it locally.
210
+ interface OTPCredential extends Credential { code: string }
211
+
212
+ /** Augmented element type used to store per-instance footer references. */
213
+ type DigitoWrapper = HTMLElement & {
214
+ __digitoFooterEl?: HTMLDivElement | null
215
+ __digitoResendRowEl?: HTMLDivElement | null
216
+ }
217
+
218
+ function mountOnWrapper(
219
+ wrapperEl: DigitoWrapper,
220
+ options: VanillaOnlyOptions,
221
+ ): DigitoInstance {
222
+ // Guard against double-initialisation on the same element. Calling initDigito
223
+ // twice without destroy() would orphan the first instance's event listeners
224
+ // and timers. Warn and clean up the stale DOM before proceeding.
225
+ if (wrapperEl.querySelector('.digito-element')) {
226
+ console.warn('[digito] initDigito() called on an already-initialised wrapper — call instance.destroy() first to avoid leaks.')
227
+ }
228
+
229
+ // ── Config ───────────────────────────────────────────────────────────────
230
+ const slotCount = options.length ?? parseInt(wrapperEl.dataset.length ?? '6', 10)
231
+ const inputType = (options.type ?? wrapperEl.dataset.type ?? 'numeric') as InputType
232
+ const timerSeconds = options.timer ?? parseInt(wrapperEl.dataset.timer ?? '0', 10)
233
+ const resendCooldown = options.resendAfter ?? parseInt(wrapperEl.dataset.resend ?? '30', 10)
234
+ const onResend = options.onResend
235
+ const onComplete = options.onComplete
236
+ const onTickCallback = options.onTick
237
+ const onExpire = options.onExpire
238
+ const pattern = options.pattern
239
+ let isDisabled = options.disabled ?? false
240
+
241
+ // New options
242
+ const autoFocus = options.autoFocus !== false // default true
243
+ const inputName = options.name
244
+ const onFocusProp = options.onFocus
245
+ const onBlurProp = options.onBlur
246
+ const placeholder = options.placeholder ?? ''
247
+ const selectOnFocus = options.selectOnFocus ?? false
248
+ const blurOnComplete = options.blurOnComplete ?? false
249
+
250
+ const rawSepAfter = (options as VanillaOnlyOptions).separatorAfter
251
+ ?? (wrapperEl.dataset.separatorAfter !== undefined
252
+ ? wrapperEl.dataset.separatorAfter.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
253
+ : [])
254
+ // Normalise to array so all rendering logic is consistent
255
+ const separatorAfterPositions: number[] = Array.isArray(rawSepAfter) ? rawSepAfter : [rawSepAfter]
256
+
257
+ const separatorChar = (options as VanillaOnlyOptions).separator
258
+ ?? wrapperEl.dataset.separator
259
+ ?? '—'
260
+ const masked = (options as VanillaOnlyOptions).masked ?? wrapperEl.dataset.masked === 'true'
261
+ const maskChar = (options as VanillaOnlyOptions).maskChar ?? wrapperEl.dataset.maskChar ?? '\u25CF'
262
+
263
+ // ── Core state machine ───────────────────────────────────────────────────
264
+ const otpCore = createDigito({
265
+ length: slotCount,
266
+ type: inputType,
267
+ timer: timerSeconds,
268
+ resendAfter: resendCooldown,
269
+ onComplete,
270
+ onTick: onTickCallback,
271
+ onExpire,
272
+ pattern,
273
+ pasteTransformer: options.pasteTransformer,
274
+ onInvalidChar: options.onInvalidChar,
275
+ sound: options.sound ?? false,
276
+ haptic: options.haptic ?? true,
277
+ disabled: isDisabled,
278
+ })
279
+
280
+ // ── Build DOM ────────────────────────────────────────────────────────────
281
+ // Clear wrapper using safe DOM removal (avoids innerHTML)
282
+ while (wrapperEl.firstChild) wrapperEl.removeChild(wrapperEl.firstChild)
283
+
284
+ const rootEl = document.createElement('div')
285
+ rootEl.className = 'digito-element'
286
+
287
+ const slotRowEl = document.createElement('div')
288
+ slotRowEl.className = 'digito-content'
289
+
290
+ const slotEls: HTMLDivElement[] = []
291
+ const caretEls: HTMLDivElement[] = []
292
+
293
+ for (let i = 0; i < slotCount; i++) {
294
+ const slotEl = document.createElement('div')
295
+ slotEl.className = 'digito-slot'
296
+ slotEl.setAttribute('aria-hidden', 'true')
297
+ slotEl.setAttribute('data-slot', String(i))
298
+
299
+ const caretEl = document.createElement('div')
300
+ caretEl.className = 'digito-caret'
301
+ caretEl.style.display = 'none'
302
+
303
+ slotEl.appendChild(caretEl)
304
+ caretEls.push(caretEl)
305
+ slotEls.push(slotEl)
306
+ slotRowEl.appendChild(slotEl)
307
+
308
+ if (separatorAfterPositions.some(pos => pos > 0 && i === pos - 1)) {
309
+ const sepEl = document.createElement('div')
310
+ sepEl.className = 'digito-separator'
311
+ sepEl.textContent = separatorChar
312
+ sepEl.setAttribute('aria-hidden', 'true')
313
+ slotRowEl.appendChild(sepEl)
314
+ }
315
+ }
316
+
317
+ const hiddenInputEl = document.createElement('input')
318
+ hiddenInputEl.type = masked ? 'password' : 'text'
319
+ hiddenInputEl.inputMode = inputType === 'numeric' ? 'numeric' : 'text'
320
+ hiddenInputEl.autocomplete = 'one-time-code'
321
+ hiddenInputEl.maxLength = slotCount
322
+ hiddenInputEl.className = 'digito-hidden-input'
323
+ if (inputName) hiddenInputEl.name = inputName
324
+ hiddenInputEl.setAttribute('aria-label', `Enter your ${slotCount}-${inputType === 'numeric' ? 'digit' : 'character'} code`)
325
+ hiddenInputEl.setAttribute('spellcheck', 'false')
326
+ hiddenInputEl.setAttribute('autocorrect', 'off')
327
+ hiddenInputEl.setAttribute('autocapitalize', 'off')
328
+
329
+ rootEl.appendChild(slotRowEl)
330
+ rootEl.appendChild(hiddenInputEl)
331
+ wrapperEl.appendChild(rootEl)
332
+
333
+ // ── Password manager badge guard ─────────────────────────────────────────
334
+ let disconnectPasswordManagerWatch: () => void = () => {}
335
+ requestAnimationFrame(() => {
336
+ const slotRowWidth = slotRowEl.getBoundingClientRect().width || 0
337
+ disconnectPasswordManagerWatch = watchForPasswordManagerBadge(hiddenInputEl, slotRowWidth)
338
+ })
339
+
340
+ // ── Timer + resend ────────────────────────────────────────────────────────
341
+ let timerBadgeEl: HTMLSpanElement | null = null
342
+ let resendActionBtn: HTMLButtonElement | null = null
343
+ let mainCountdown: ReturnType<typeof createTimer> | null = null
344
+ let resendCountdown: ReturnType<typeof createTimer> | null = null
345
+ let builtInFooterEl: HTMLDivElement | null = null
346
+ let builtInResendRowEl: HTMLDivElement | null = null
347
+
348
+ // Remove any footer elements left by a previous mount on this same wrapper.
349
+ // Using stored references is reliable regardless of DOM siblings inserted between them.
350
+ wrapperEl.__digitoFooterEl?.remove()
351
+ wrapperEl.__digitoResendRowEl?.remove()
352
+ wrapperEl.__digitoFooterEl = null
353
+ wrapperEl.__digitoResendRowEl = null
354
+
355
+ if (timerSeconds > 0) {
356
+ const shouldUseBuiltInFooter = !onTickCallback
357
+
358
+ if (shouldUseBuiltInFooter) {
359
+ builtInFooterEl = document.createElement('div')
360
+ builtInFooterEl.className = 'digito-timer'
361
+
362
+ const expiresLabel = document.createElement('span')
363
+ expiresLabel.className = 'digito-timer-label'
364
+ expiresLabel.textContent = 'Code expires in'
365
+
366
+ timerBadgeEl = document.createElement('span')
367
+ timerBadgeEl.className = 'digito-timer-badge'
368
+ timerBadgeEl.textContent = formatCountdown(timerSeconds)
369
+
370
+ builtInFooterEl.appendChild(expiresLabel)
371
+ builtInFooterEl.appendChild(timerBadgeEl)
372
+ wrapperEl.insertAdjacentElement('afterend', builtInFooterEl)
373
+
374
+ builtInResendRowEl = document.createElement('div')
375
+ builtInResendRowEl.className = 'digito-resend'
376
+
377
+ const didntReceiveLabel = document.createElement('span')
378
+ didntReceiveLabel.textContent = 'Didn\u2019t receive the code?'
379
+
380
+ resendActionBtn = document.createElement('button')
381
+ resendActionBtn.className = 'digito-resend-btn'
382
+ resendActionBtn.textContent = 'Resend'
383
+ resendActionBtn.type = 'button'
384
+
385
+ builtInResendRowEl.appendChild(didntReceiveLabel)
386
+ builtInResendRowEl.appendChild(resendActionBtn)
387
+ builtInFooterEl.insertAdjacentElement('afterend', builtInResendRowEl)
388
+
389
+ // Store on the wrapper element so subsequent mounts on the same element
390
+ // can clean these up reliably without fragile sibling DOM walks.
391
+ wrapperEl.__digitoFooterEl = builtInFooterEl
392
+ wrapperEl.__digitoResendRowEl = builtInResendRowEl
393
+ }
394
+
395
+ mainCountdown = createTimer({
396
+ totalSeconds: timerSeconds,
397
+ onTick: (remaining) => {
398
+ if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(remaining)
399
+ onTickCallback?.(remaining)
400
+ },
401
+ onExpire: () => {
402
+ if (builtInFooterEl) builtInFooterEl.style.display = 'none'
403
+ if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
404
+ onExpire?.()
405
+ },
406
+ })
407
+ mainCountdown.start()
408
+
409
+ if (shouldUseBuiltInFooter && resendActionBtn) {
410
+ resendActionBtn.addEventListener('click', () => {
411
+ if (!resendActionBtn || !timerBadgeEl || !builtInFooterEl || !builtInResendRowEl) return
412
+ builtInResendRowEl.classList.remove('is-visible')
413
+ builtInFooterEl.style.display = 'flex'
414
+ timerBadgeEl.textContent = formatCountdown(resendCooldown)
415
+ resendCountdown?.stop()
416
+ resendCountdown = createTimer({
417
+ totalSeconds: resendCooldown,
418
+ onTick: (r) => { if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(r) },
419
+ onExpire: () => {
420
+ if (builtInFooterEl) builtInFooterEl.style.display = 'none'
421
+ if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
422
+ },
423
+ })
424
+ resendCountdown.start()
425
+ onResend?.()
426
+ })
427
+ }
428
+ }
429
+
430
+ // ── Web OTP API (SMS autofill) ────────────────────────────────────────────
431
+ // navigator.credentials.get({ otp: { transport: ['sms'] } }) intercepts
432
+ // incoming OTP SMSes on Android Chrome without any user gesture.
433
+ // The AbortController is stored so destroy() can cancel the pending request.
434
+ let webOTPController: AbortController | null = null
435
+
436
+ if (typeof navigator !== 'undefined' && 'credentials' in navigator) {
437
+ webOTPController = new AbortController()
438
+ ;(navigator.credentials.get as (opts: object) => Promise<OTPCredential | null>)({
439
+ otp: { transport: ['sms'] },
440
+ signal: webOTPController.signal,
441
+ }).then((credential) => {
442
+ if (!credential?.code) return
443
+ const valid = filterString(credential.code, inputType, pattern).slice(0, slotCount)
444
+ if (!valid) return
445
+ otpCore.resetState()
446
+ for (let i = 0; i < valid.length; i++) {
447
+ if (valid[i]) otpCore.inputChar(i, valid[i])
448
+ }
449
+ const filledCount = valid.length
450
+ const nextCursor = Math.min(filledCount, slotCount - 1)
451
+ hiddenInputEl.value = valid
452
+ hiddenInputEl.setSelectionRange(nextCursor, nextCursor)
453
+ otpCore.moveFocusTo(nextCursor)
454
+ syncSlotsToDOM()
455
+ }).catch(() => { /* aborted on destroy() or not supported — fail silently */ })
456
+ }
457
+
458
+
459
+ // ── DOM sync ─────────────────────────────────────────────────────────────
460
+
461
+ function syncSlotsToDOM(): void {
462
+ const { slotValues, activeSlot, hasError } = otpCore.state
463
+ const inputIsFocused = document.activeElement === hiddenInputEl
464
+
465
+ slotEls.forEach((slotEl, i) => {
466
+ const char = slotValues[i] ?? ''
467
+ const isActive = i === activeSlot && inputIsFocused
468
+ const isFilled = char.length === 1
469
+
470
+ let textNode = slotEl.childNodes[1] as Text | undefined
471
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
472
+ textNode = document.createTextNode('')
473
+ slotEl.appendChild(textNode)
474
+ }
475
+ textNode.nodeValue = masked && char ? maskChar : char || placeholder
476
+
477
+ slotEl.classList.toggle('is-active', isActive)
478
+ slotEl.classList.toggle('is-filled', isFilled)
479
+ slotEl.classList.toggle('is-masked', masked)
480
+ slotEl.classList.toggle('is-error', hasError)
481
+ if (hasError) slotEl.classList.remove('is-success')
482
+
483
+ caretEls[i].style.display = isActive && !isFilled ? 'block' : 'none'
484
+ })
485
+
486
+ // Only update value when it actually differs — assigning the same string
487
+ // resets selectionStart/End in some browsers, clobbering the cursor.
488
+ const newValue = slotValues.join('')
489
+ if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
490
+ }
491
+
492
+ // ── Event handlers ────────────────────────────────────────────────────────
493
+
494
+ function onHiddenInputKeydown(event: KeyboardEvent): void {
495
+ const cursorPos = hiddenInputEl.selectionStart ?? 0
496
+
497
+ if (event.key === 'Backspace') {
498
+ event.preventDefault()
499
+ otpCore.deleteChar(cursorPos)
500
+ syncSlotsToDOM()
501
+ hiddenInputEl.setSelectionRange(otpCore.state.activeSlot, otpCore.state.activeSlot)
502
+ } else if (event.key === 'Tab') {
503
+ if (event.shiftKey) {
504
+ // Shift+Tab: move to previous slot, or exit to previous DOM element from first slot
505
+ if (cursorPos === 0) return
506
+ event.preventDefault()
507
+ otpCore.moveFocusLeft(cursorPos)
508
+ } else {
509
+ // Tab: advance to next slot only if current slot is filled
510
+ // On last slot (filled), fall through to let browser move to next DOM element
511
+ if (!otpCore.state.slotValues[cursorPos]) return
512
+ if (cursorPos >= slotCount - 1) return
513
+ event.preventDefault()
514
+ otpCore.moveFocusRight(cursorPos)
515
+ }
516
+ const nextSlot = otpCore.state.activeSlot
517
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot)
518
+ syncSlotsToDOM()
519
+ } else if (event.key === 'ArrowLeft') {
520
+ event.preventDefault()
521
+ otpCore.moveFocusLeft(cursorPos)
522
+ const nextSlot = otpCore.state.activeSlot
523
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot)
524
+ syncSlotsToDOM()
525
+ } else if (event.key === 'ArrowRight') {
526
+ event.preventDefault()
527
+ otpCore.moveFocusRight(cursorPos)
528
+ const nextSlot = otpCore.state.activeSlot
529
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot)
530
+ syncSlotsToDOM()
531
+ }
532
+ }
533
+
534
+ function onHiddenInputChange(_event: Event): void {
535
+ const rawValue = hiddenInputEl.value
536
+
537
+ if (!rawValue) {
538
+ otpCore.resetState()
539
+ hiddenInputEl.value = ''
540
+ hiddenInputEl.setSelectionRange(0, 0)
541
+ syncSlotsToDOM()
542
+ return
543
+ }
544
+
545
+ const validValue = filterString(rawValue, inputType, pattern)
546
+
547
+ const newSlotValues = Array(slotCount).fill('') as string[]
548
+ for (let i = 0; i < Math.min(validValue.length, slotCount); i++) {
549
+ newSlotValues[i] = validValue[i]
550
+ }
551
+
552
+ otpCore.resetState()
553
+ for (let i = 0; i < newSlotValues.length; i++) {
554
+ if (newSlotValues[i]) otpCore.inputChar(i, newSlotValues[i])
555
+ }
556
+
557
+ const filledCount = newSlotValues.filter(v => v !== '').length
558
+ const nextCursor = Math.min(filledCount, slotCount - 1)
559
+ hiddenInputEl.value = validValue.slice(0, slotCount)
560
+ hiddenInputEl.setSelectionRange(nextCursor, nextCursor)
561
+ otpCore.moveFocusTo(nextCursor)
562
+ syncSlotsToDOM()
563
+ if (blurOnComplete && otpCore.state.isComplete) {
564
+ requestAnimationFrame(() => hiddenInputEl.blur())
565
+ }
566
+ }
567
+
568
+ function onHiddenInputPaste(event: ClipboardEvent): void {
569
+ event.preventDefault()
570
+ const pastedText = event.clipboardData?.getData('text') ?? ''
571
+ const cursorPos = hiddenInputEl.selectionStart ?? 0
572
+ otpCore.pasteString(cursorPos, pastedText)
573
+
574
+ const { slotValues, activeSlot } = otpCore.state
575
+ hiddenInputEl.value = slotValues.join('')
576
+ hiddenInputEl.setSelectionRange(activeSlot, activeSlot)
577
+ syncSlotsToDOM()
578
+ if (blurOnComplete && otpCore.state.isComplete) {
579
+ requestAnimationFrame(() => hiddenInputEl.blur())
580
+ }
581
+ }
582
+
583
+ function onHiddenInputFocus(): void {
584
+ onFocusProp?.()
585
+ // Read activeSlot inside the RAF — not before it.
586
+ // Browser event order on a fresh click: focus fires → click fires → RAF fires.
587
+ // Capturing pos outside the RAF would snapshot activeSlot=0 before the click
588
+ // handler has a chance to call moveFocusTo(clickedSlot), causing the RAF to
589
+ // overwrite the correct slot back to 0.
590
+ requestAnimationFrame(() => {
591
+ const pos = otpCore.state.activeSlot
592
+ const char = otpCore.state.slotValues[pos]
593
+ if (selectOnFocus && char) {
594
+ hiddenInputEl.setSelectionRange(pos, pos + 1)
595
+ } else {
596
+ hiddenInputEl.setSelectionRange(pos, pos)
597
+ }
598
+ syncSlotsToDOM()
599
+ })
600
+ }
601
+
602
+ function onHiddenInputBlur(): void {
603
+ onBlurProp?.()
604
+ slotEls.forEach(slotEl => slotEl.classList.remove('is-active'))
605
+ caretEls.forEach(caretEl => { caretEl.style.display = 'none' })
606
+ }
607
+
608
+ hiddenInputEl.addEventListener('keydown', onHiddenInputKeydown)
609
+ hiddenInputEl.addEventListener('input', onHiddenInputChange)
610
+ hiddenInputEl.addEventListener('paste', onHiddenInputPaste)
611
+ hiddenInputEl.addEventListener('focus', onHiddenInputFocus)
612
+ hiddenInputEl.addEventListener('blur', onHiddenInputBlur)
613
+ hiddenInputEl.addEventListener('click', onHiddenInputClick)
614
+
615
+ function onHiddenInputClick(e: MouseEvent): void {
616
+ if (isDisabled) return
617
+ // Click fires after the browser has already placed the cursor (at 0 due to
618
+ // font-size:1px). Coordinate hit-test to find the intended slot, then
619
+ // override the browser's placement with an explicit setSelectionRange.
620
+ let rawSlot = slotEls.length - 1
621
+ for (let i = 0; i < slotEls.length; i++) {
622
+ if (e.clientX <= slotEls[i].getBoundingClientRect().right) { rawSlot = i; break }
623
+ }
624
+ // Clamp to filled count: setSelectionRange(N, N) on a string of length L
625
+ // silently clamps to L, so cursor ends up at 0 on an empty field. Clamping
626
+ // keeps the visual active slot and the actual cursor position in sync.
627
+ const clickedSlot = Math.min(rawSlot, hiddenInputEl.value.length)
628
+ otpCore.moveFocusTo(clickedSlot)
629
+ const char = otpCore.state.slotValues[clickedSlot]
630
+ if (selectOnFocus && char) {
631
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot + 1)
632
+ } else {
633
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot)
634
+ }
635
+ syncSlotsToDOM()
636
+ }
637
+
638
+ requestAnimationFrame(() => {
639
+ if (!isDisabled && autoFocus) hiddenInputEl.focus()
640
+ hiddenInputEl.setSelectionRange(0, 0)
641
+ syncSlotsToDOM()
642
+ })
643
+
644
+
645
+ // ── Public API ────────────────────────────────────────────────────────────
646
+
647
+ function reset(): void {
648
+ otpCore.resetState()
649
+ hiddenInputEl.value = ''
650
+ if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSeconds)
651
+ if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
652
+ if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
653
+ resendCountdown?.stop()
654
+ mainCountdown?.restart()
655
+ requestAnimationFrame(() => {
656
+ hiddenInputEl.focus()
657
+ hiddenInputEl.setSelectionRange(0, 0)
658
+ syncSlotsToDOM()
659
+ })
660
+ }
661
+
662
+ function resend(): void {
663
+ reset()
664
+ onResend?.()
665
+ }
666
+
667
+ function setError(isError: boolean): void {
668
+ otpCore.setError(isError)
669
+ syncSlotsToDOM()
670
+ }
671
+
672
+ function setSuccess(isSuccess: boolean): void {
673
+ slotEls.forEach(slotEl => {
674
+ slotEl.classList.toggle('is-success', isSuccess)
675
+ if (isSuccess) slotEl.classList.remove('is-error')
676
+ })
677
+ }
678
+
679
+ function setDisabled(value: boolean): void {
680
+ isDisabled = value
681
+ otpCore.setDisabled(value)
682
+ hiddenInputEl.disabled = value
683
+ slotEls.forEach(slotEl => {
684
+ slotEl.classList.toggle('is-disabled', value)
685
+ ;(slotEl as HTMLElement).style.pointerEvents = value ? 'none' : ''
686
+ })
687
+ }
688
+
689
+ function getCode(): string {
690
+ return otpCore.getCode()
691
+ }
692
+
693
+ function focus(slotIndex: number): void {
694
+ otpCore.moveFocusTo(slotIndex)
695
+ hiddenInputEl.focus()
696
+ hiddenInputEl.setSelectionRange(slotIndex, slotIndex)
697
+ syncSlotsToDOM()
698
+ }
699
+
700
+ function destroy(): void {
701
+ hiddenInputEl.removeEventListener('keydown', onHiddenInputKeydown)
702
+ hiddenInputEl.removeEventListener('input', onHiddenInputChange)
703
+ hiddenInputEl.removeEventListener('paste', onHiddenInputPaste)
704
+ hiddenInputEl.removeEventListener('focus', onHiddenInputFocus)
705
+ hiddenInputEl.removeEventListener('blur', onHiddenInputBlur)
706
+ hiddenInputEl.removeEventListener('click', onHiddenInputClick)
707
+ mainCountdown?.stop()
708
+ resendCountdown?.stop()
709
+ disconnectPasswordManagerWatch()
710
+ webOTPController?.abort()
711
+ wrapperEl.__digitoFooterEl = null
712
+ wrapperEl.__digitoResendRowEl = null
713
+ }
714
+
715
+ return { reset, resend, setError, setSuccess, setDisabled, getCode, focus, destroy }
716
+ }
717
+
718
+
719
+ // ─────────────────────────────────────────────────────────────────────────────
720
+ // HELPERS
721
+ // ─────────────────────────────────────────────────────────────────────────────
722
+
723
+ function formatCountdown(totalSeconds: number): string {
724
+ const minutes = Math.floor(totalSeconds / 60)
725
+ const seconds = totalSeconds % 60
726
+ return minutes > 0
727
+ ? `${minutes}:${String(seconds).padStart(2, '0')}`
728
+ : `0:${String(seconds).padStart(2, '0')}`
729
+ }
730
+
731
+
732
+ // ─────────────────────────────────────────────────────────────────────────────
733
+ // PASSWORD MANAGER BADGE GUARD
734
+ // ─────────────────────────────────────────────────────────────────────────────
735
+ //
736
+ // Password managers (LastPass, 1Password, Dashlane, Bitwarden) inject a small
737
+ // icon badge into or beside <input> elements they detect as credential fields.
738
+ // On OTP inputs this badge physically overlaps the last visual slot.
739
+ //
740
+ // Fix: detect when any of these extensions are active, then push the hidden
741
+ // input's width ~40px wider so the badge renders outside the slot boundary.
742
+
743
+ const PASSWORD_MANAGER_SELECTORS = [
744
+ '[data-lastpass-icon-root]',
745
+ '[data-lastpass-root]',
746
+ '[data-op-autofill]',
747
+ '[data-1p-ignore]',
748
+ '[data-dashlane-rid]',
749
+ '[data-dashlane-label]',
750
+ '[data-kwimpalastatus]',
751
+ '[data-bwautofill]',
752
+ 'com-bitwarden-browser-arctic-modal',
753
+ ]
754
+
755
+ const PASSWORD_MANAGER_BADGE_OFFSET_PX = 40
756
+
757
+ function isPasswordManagerActive(): boolean {
758
+ if (typeof document === 'undefined') return false
759
+ return PASSWORD_MANAGER_SELECTORS.some(sel => {
760
+ try { return document.querySelector(sel) !== null }
761
+ catch { return false }
762
+ })
763
+ }
764
+
765
+ function watchForPasswordManagerBadge(
766
+ hiddenInputEl: HTMLInputElement,
767
+ baseWidthPx: number,
768
+ ): () => void {
769
+ if (typeof MutationObserver === 'undefined') return () => {}
770
+
771
+ function applyOffset(): void {
772
+ hiddenInputEl.style.width = `${baseWidthPx + PASSWORD_MANAGER_BADGE_OFFSET_PX}px`
773
+ }
774
+
775
+ if (isPasswordManagerActive()) {
776
+ applyOffset()
777
+ return () => {}
778
+ }
779
+
780
+ const observer = new MutationObserver(() => {
781
+ if (isPasswordManagerActive()) {
782
+ applyOffset()
783
+ observer.disconnect()
784
+ }
785
+ })
786
+
787
+ observer.observe(document.documentElement, {
788
+ childList: true,
789
+ subtree: true,
790
+ attributes: true,
791
+ attributeFilter: [
792
+ 'data-lastpass-icon-root',
793
+ 'data-lastpass-root',
794
+ 'data-1p-ignore',
795
+ 'data-dashlane-rid',
796
+ 'data-kwimpalastatus',
797
+ ],
798
+ })
799
+
800
+ return () => observer.disconnect()
801
+ }
802
+
803
+
804
+ // ─────────────────────────────────────────────────────────────────────────────
805
+ // CDN GLOBAL
806
+ // ─────────────────────────────────────────────────────────────────────────────
807
+
808
+ if (typeof window !== 'undefined') {
809
+ (window as unknown as Record<string, unknown>)['Digito'] = { init: initDigito }
810
+ }