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