digitojs 1.0.0 → 1.2.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.
- package/CHANGELOG.md +15 -0
- package/README.md +114 -41
- package/dist/adapters/alpine.d.ts.map +1 -1
- package/dist/adapters/alpine.js +65 -58
- package/dist/adapters/alpine.js.map +1 -1
- package/dist/adapters/react.d.ts +9 -0
- package/dist/adapters/react.d.ts.map +1 -1
- package/dist/adapters/react.js +45 -4
- package/dist/adapters/react.js.map +1 -1
- package/dist/adapters/svelte.d.ts +16 -14
- package/dist/adapters/svelte.d.ts.map +1 -1
- package/dist/adapters/svelte.js +34 -4
- package/dist/adapters/svelte.js.map +1 -1
- package/dist/adapters/vanilla.d.ts.map +1 -1
- package/dist/adapters/vanilla.js +36 -12
- package/dist/adapters/vanilla.js.map +1 -1
- package/dist/adapters/vue.d.ts +2 -0
- package/dist/adapters/vue.d.ts.map +1 -1
- package/dist/adapters/vue.js +35 -4
- package/dist/adapters/vue.js.map +1 -1
- package/dist/adapters/web-component.d.ts +3 -0
- package/dist/adapters/web-component.d.ts.map +1 -1
- package/dist/adapters/web-component.js +37 -12
- package/dist/adapters/web-component.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/machine.d.ts +3 -0
- package/dist/core/machine.d.ts.map +1 -1
- package/dist/core/machine.js +31 -3
- package/dist/core/machine.js.map +1 -1
- package/dist/core/timer.d.ts +8 -0
- package/dist/core/timer.d.ts.map +1 -1
- package/dist/core/timer.js +14 -0
- package/dist/core/timer.js.map +1 -1
- package/dist/core/types.d.ts +30 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/digito-wc.min.js +2 -2
- package/dist/digito-wc.min.js.map +3 -3
- package/dist/digito.min.js +1 -1
- package/dist/digito.min.js.map +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
- package/src/adapters/alpine.ts +58 -50
- package/src/adapters/react.tsx +50 -5
- package/src/adapters/svelte.ts +48 -16
- package/src/adapters/vanilla.ts +32 -14
- package/src/adapters/vue.ts +32 -3
- package/src/adapters/web-component.ts +35 -13
- package/src/core/index.ts +1 -1
- package/src/core/machine.ts +31 -3
- package/src/core/timer.ts +15 -0
- package/src/core/types.ts +30 -2
- package/src/index.ts +0 -1
package/src/adapters/alpine.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
createDigito,
|
|
29
29
|
createTimer,
|
|
30
30
|
filterString,
|
|
31
|
+
formatCountdown,
|
|
31
32
|
type DigitoOptions,
|
|
32
33
|
type InputType,
|
|
33
34
|
} from '../core/index.js'
|
|
@@ -96,18 +97,6 @@ type AlpineOTPOptions = DigitoOptions & {
|
|
|
96
97
|
maskChar?: string
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
-
// HELPERS
|
|
101
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
function formatCountdown(totalSeconds: number): string {
|
|
104
|
-
const minutes = Math.floor(totalSeconds / 60)
|
|
105
|
-
const seconds = totalSeconds % 60
|
|
106
|
-
return minutes > 0
|
|
107
|
-
? `${minutes}:${String(seconds).padStart(2, '0')}`
|
|
108
|
-
: `0:${String(seconds).padStart(2, '0')}`
|
|
109
|
-
}
|
|
110
|
-
|
|
111
100
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
101
|
// PLUGIN
|
|
113
102
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -170,12 +159,14 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
170
159
|
placeholder = '',
|
|
171
160
|
selectOnFocus = false,
|
|
172
161
|
blurOnComplete = false,
|
|
162
|
+
defaultValue = '',
|
|
163
|
+
readOnly: readOnlyOpt = false,
|
|
173
164
|
} = options
|
|
174
165
|
|
|
175
166
|
// Normalise separatorAfter to an array for consistent rendering
|
|
176
167
|
const separatorAfterPositions: number[] = Array.isArray(rawSepAfter) ? rawSepAfter : [rawSepAfter]
|
|
177
168
|
|
|
178
|
-
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
|
|
169
|
+
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound, readOnly: readOnlyOpt })
|
|
179
170
|
|
|
180
171
|
let isDisabled = initialDisabled
|
|
181
172
|
let successState = false
|
|
@@ -267,8 +258,19 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
267
258
|
hiddenInputEl.setAttribute('autocapitalize', 'off')
|
|
268
259
|
hiddenInputEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;border:none;outline:none;background:transparent;color:transparent;caret-color:transparent;z-index:1;cursor:text;font-size:1px'
|
|
269
260
|
if (inputName) hiddenInputEl.name = inputName
|
|
261
|
+
if (readOnlyOpt) hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
270
262
|
wrapperEl.appendChild(hiddenInputEl)
|
|
271
263
|
|
|
264
|
+
// Apply defaultValue once on mount — no onComplete, no onChange
|
|
265
|
+
if (defaultValue) {
|
|
266
|
+
const filtered = filterString(defaultValue.slice(0, length), type, pattern)
|
|
267
|
+
if (filtered) {
|
|
268
|
+
for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
|
|
269
|
+
digito.cancelPendingComplete()
|
|
270
|
+
hiddenInputEl.value = filtered
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
272
274
|
// ── Built-in timer + resend (mirrors vanilla adapter) ──────────────────────
|
|
273
275
|
let timerBadgeEl: HTMLSpanElement | null = null
|
|
274
276
|
let resendActionBtn: HTMLButtonElement | null = null
|
|
@@ -427,6 +429,11 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
427
429
|
// resets selectionStart/End in some browsers, clobbering the cursor.
|
|
428
430
|
const newValue = slotValues.join('')
|
|
429
431
|
if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
|
|
432
|
+
|
|
433
|
+
wrapperEl.toggleAttribute('data-complete', digito.state.isComplete)
|
|
434
|
+
wrapperEl.toggleAttribute('data-invalid', digito.state.hasError)
|
|
435
|
+
wrapperEl.toggleAttribute('data-disabled', isDisabled)
|
|
436
|
+
wrapperEl.toggleAttribute('data-readonly', readOnlyOpt)
|
|
430
437
|
}
|
|
431
438
|
|
|
432
439
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
|
@@ -435,11 +442,19 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
435
442
|
const pos = hiddenInputEl.selectionStart ?? 0
|
|
436
443
|
if (e.key === 'Backspace') {
|
|
437
444
|
e.preventDefault()
|
|
445
|
+
if (readOnlyOpt) return
|
|
438
446
|
digito.deleteChar(pos)
|
|
439
447
|
syncSlotsToDOM()
|
|
440
448
|
onChangeProp?.(digito.getCode())
|
|
441
449
|
const next = digito.state.activeSlot
|
|
442
450
|
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
451
|
+
} else if (e.key === 'Delete') {
|
|
452
|
+
e.preventDefault()
|
|
453
|
+
if (readOnlyOpt) return
|
|
454
|
+
digito.clearSlot(pos)
|
|
455
|
+
syncSlotsToDOM()
|
|
456
|
+
onChangeProp?.(digito.getCode())
|
|
457
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(pos, pos))
|
|
443
458
|
} else if (e.key === 'ArrowLeft') {
|
|
444
459
|
e.preventDefault()
|
|
445
460
|
digito.moveFocusLeft(pos)
|
|
@@ -470,7 +485,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
470
485
|
})
|
|
471
486
|
|
|
472
487
|
hiddenInputEl.addEventListener('input', () => {
|
|
473
|
-
if (isDisabled) return
|
|
488
|
+
if (isDisabled || readOnlyOpt) return
|
|
474
489
|
const raw = hiddenInputEl.value
|
|
475
490
|
if (!raw) {
|
|
476
491
|
digito.resetState()
|
|
@@ -495,7 +510,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
495
510
|
})
|
|
496
511
|
|
|
497
512
|
hiddenInputEl.addEventListener('paste', (e) => {
|
|
498
|
-
if (isDisabled) return
|
|
513
|
+
if (isDisabled || readOnlyOpt) return
|
|
499
514
|
e.preventDefault()
|
|
500
515
|
const text = e.clipboardData?.getData('text') ?? ''
|
|
501
516
|
const pos = hiddenInputEl.selectionStart ?? 0
|
|
@@ -557,6 +572,30 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
557
572
|
syncSlotsToDOM()
|
|
558
573
|
})
|
|
559
574
|
|
|
575
|
+
// ── Internal helpers (shared by public API methods below) ─────────────────
|
|
576
|
+
|
|
577
|
+
/** Tears down running timers and removes built-in footer elements from the DOM. */
|
|
578
|
+
function teardown(): void {
|
|
579
|
+
mainCountdown?.stop()
|
|
580
|
+
resendCountdown?.stop()
|
|
581
|
+
builtInFooterEl?.remove()
|
|
582
|
+
builtInResendRowEl?.remove()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Resets slot state, restarts timers, and restores focus — shared by reset() and resend(). */
|
|
586
|
+
function doReset(): void {
|
|
587
|
+
digito.resetState()
|
|
588
|
+
hiddenInputEl.value = ''
|
|
589
|
+
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
590
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
591
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
592
|
+
resendCountdown?.stop()
|
|
593
|
+
mainCountdown?.restart()
|
|
594
|
+
if (!isDisabled) hiddenInputEl.focus()
|
|
595
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
596
|
+
syncSlotsToDOM()
|
|
597
|
+
}
|
|
598
|
+
|
|
560
599
|
// ── Public API on element ──────────────────────────────────────────────────
|
|
561
600
|
// Exposed on `el._digito` for programmatic control from Alpine components or
|
|
562
601
|
// external JavaScript. Mirrors the DigitoInstance interface from the vanilla adapter.
|
|
@@ -565,41 +604,13 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
565
604
|
getCode: () => digito.getCode(),
|
|
566
605
|
|
|
567
606
|
/** Stop timers and remove built-in footer elements. Call before removing the element. */
|
|
568
|
-
destroy: () =>
|
|
569
|
-
mainCountdown?.stop()
|
|
570
|
-
resendCountdown?.stop()
|
|
571
|
-
builtInFooterEl?.remove()
|
|
572
|
-
builtInResendRowEl?.remove()
|
|
573
|
-
},
|
|
607
|
+
destroy: () => teardown(),
|
|
574
608
|
|
|
575
609
|
/** Clear all slots, re-focus, reset to idle state, and restart the built-in timer. */
|
|
576
|
-
reset: () =>
|
|
577
|
-
digito.resetState()
|
|
578
|
-
hiddenInputEl.value = ''
|
|
579
|
-
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
580
|
-
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
581
|
-
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
582
|
-
resendCountdown?.stop()
|
|
583
|
-
mainCountdown?.restart()
|
|
584
|
-
if (!isDisabled) hiddenInputEl.focus()
|
|
585
|
-
hiddenInputEl.setSelectionRange(0, 0)
|
|
586
|
-
syncSlotsToDOM()
|
|
587
|
-
},
|
|
610
|
+
reset: () => doReset(),
|
|
588
611
|
|
|
589
612
|
/** Reset and fire the `onResend` callback. */
|
|
590
|
-
resend: () => {
|
|
591
|
-
digito.resetState()
|
|
592
|
-
hiddenInputEl.value = ''
|
|
593
|
-
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
594
|
-
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
595
|
-
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
596
|
-
resendCountdown?.stop()
|
|
597
|
-
mainCountdown?.restart()
|
|
598
|
-
if (!isDisabled) hiddenInputEl.focus()
|
|
599
|
-
hiddenInputEl.setSelectionRange(0, 0)
|
|
600
|
-
syncSlotsToDOM()
|
|
601
|
-
onResend?.()
|
|
602
|
-
},
|
|
613
|
+
resend: () => { doReset(); onResend?.() },
|
|
603
614
|
|
|
604
615
|
/** Apply or clear the error state on all visual slots. */
|
|
605
616
|
setError: (isError: boolean) => {
|
|
@@ -655,10 +666,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
655
666
|
return {
|
|
656
667
|
/** Alpine calls this when the component is destroyed. Stops timers and removes footer elements. */
|
|
657
668
|
cleanup() {
|
|
658
|
-
|
|
659
|
-
resendCountdown?.stop()
|
|
660
|
-
builtInFooterEl?.remove()
|
|
661
|
-
builtInResendRowEl?.remove()
|
|
669
|
+
teardown()
|
|
662
670
|
digito.resetState()
|
|
663
671
|
},
|
|
664
672
|
}
|
package/src/adapters/react.tsx
CHANGED
|
@@ -61,6 +61,13 @@ export type ReactOTPOptions = DigitoOptions & {
|
|
|
61
61
|
* Compatible with react-hook-form via <Controller>.
|
|
62
62
|
*/
|
|
63
63
|
value?: string
|
|
64
|
+
/**
|
|
65
|
+
* Uncontrolled initial value. Applied once on mount when `value` is undefined.
|
|
66
|
+
* Does not trigger `onComplete` or `onChange`.
|
|
67
|
+
*/
|
|
68
|
+
defaultValue?: string
|
|
69
|
+
/** When `true`, mutations are blocked; focus/navigation/copy remain allowed. */
|
|
70
|
+
readOnly?: boolean
|
|
64
71
|
/**
|
|
65
72
|
* Fires exactly ONCE per user interaction with the current joined code string.
|
|
66
73
|
* Receives partial values too — not just when the code is complete.
|
|
@@ -211,6 +218,8 @@ export type UseOTPResult = {
|
|
|
211
218
|
setError: (isError: boolean) => void
|
|
212
219
|
/** Programmatically move focus to a specific slot index. */
|
|
213
220
|
focus: (slotIndex: number) => void
|
|
221
|
+
/** Spread onto the wrapper element to expose state as data attributes for CSS targeting. */
|
|
222
|
+
wrapperProps: Record<string, string | undefined>
|
|
214
223
|
/**
|
|
215
224
|
* The separator slot index/indices for JSX rendering.
|
|
216
225
|
* Insert a visual divider AFTER each position. `0` / empty array = no separator.
|
|
@@ -268,6 +277,8 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
268
277
|
pasteTransformer,
|
|
269
278
|
onInvalidChar,
|
|
270
279
|
value: controlledValue,
|
|
280
|
+
defaultValue,
|
|
281
|
+
readOnly: readOnlyProp = false,
|
|
271
282
|
onChange: onChangeProp,
|
|
272
283
|
onFocus: onFocusProp,
|
|
273
284
|
onBlur: onBlurProp,
|
|
@@ -308,6 +319,7 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
308
319
|
const digitoRef = useRef(
|
|
309
320
|
createDigito({
|
|
310
321
|
length, type, haptic, sound, pattern, pasteTransformer,
|
|
322
|
+
readOnly: readOnlyProp,
|
|
311
323
|
onComplete: (code) => onCompleteRef.current?.(code),
|
|
312
324
|
onExpire: () => onExpireRef.current?.(),
|
|
313
325
|
onResend: () => onResendRef.current?.(),
|
|
@@ -316,9 +328,13 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
316
328
|
)
|
|
317
329
|
const digito = digitoRef.current
|
|
318
330
|
|
|
319
|
-
// ── Disabled
|
|
320
|
-
|
|
321
|
-
|
|
331
|
+
// ── Disabled / readOnly refs ────────────────────────────────────────────────
|
|
332
|
+
// Stored in refs so memoized callbacks (useCallback with [] deps) always read
|
|
333
|
+
// the latest value without needing to be recreated on every render.
|
|
334
|
+
const disabledRef = useRef(disabled)
|
|
335
|
+
const readOnlyRef = useRef(readOnlyProp)
|
|
336
|
+
useEffect(() => { disabledRef.current = disabled }, [disabled])
|
|
337
|
+
useEffect(() => { readOnlyRef.current = readOnlyProp }, [readOnlyProp])
|
|
322
338
|
|
|
323
339
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
324
340
|
const [state, setState] = useState<DigitoState>(digito.state)
|
|
@@ -364,6 +380,19 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
364
380
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
365
381
|
}, [controlledValue, length])
|
|
366
382
|
|
|
383
|
+
// ── defaultValue — applied once on mount when no controlled value is present ─
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (controlledValue !== undefined || !defaultValue) return
|
|
386
|
+
const filtered = filterString(defaultValue.slice(0, length), type, pattern)
|
|
387
|
+
if (!filtered) return
|
|
388
|
+
digito.resetState()
|
|
389
|
+
for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
|
|
390
|
+
digito.cancelPendingComplete()
|
|
391
|
+
setState({ ...digito.state })
|
|
392
|
+
if (inputRef.current) { inputRef.current.value = filtered; inputRef.current.setSelectionRange(filtered.length, filtered.length) }
|
|
393
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
394
|
+
}, [])
|
|
395
|
+
|
|
367
396
|
// ── Timer ──────────────────────────────────────────────────────────────────
|
|
368
397
|
useEffect(() => {
|
|
369
398
|
if (!timerSecs) return
|
|
@@ -385,10 +414,17 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
385
414
|
const pos = inputRef.current?.selectionStart ?? 0
|
|
386
415
|
if (e.key === 'Backspace') {
|
|
387
416
|
e.preventDefault()
|
|
417
|
+
if (readOnlyRef.current) return
|
|
388
418
|
digito.deleteChar(pos)
|
|
389
419
|
sync()
|
|
390
420
|
const next = digito.state.activeSlot
|
|
391
421
|
requestAnimationFrame(() => inputRef.current?.setSelectionRange(next, next))
|
|
422
|
+
} else if (e.key === 'Delete') {
|
|
423
|
+
e.preventDefault()
|
|
424
|
+
if (readOnlyRef.current) return
|
|
425
|
+
digito.clearSlot(pos)
|
|
426
|
+
sync()
|
|
427
|
+
requestAnimationFrame(() => inputRef.current?.setSelectionRange(pos, pos))
|
|
392
428
|
} else if (e.key === 'ArrowLeft') {
|
|
393
429
|
e.preventDefault()
|
|
394
430
|
digito.moveFocusLeft(pos)
|
|
@@ -419,7 +455,7 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
419
455
|
}, [])
|
|
420
456
|
|
|
421
457
|
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
422
|
-
if (disabledRef.current) return
|
|
458
|
+
if (disabledRef.current || readOnlyRef.current) return
|
|
423
459
|
const raw = e.target.value
|
|
424
460
|
if (!raw) {
|
|
425
461
|
digito.resetState()
|
|
@@ -440,7 +476,7 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
440
476
|
}, [type, length, blurOnComplete])
|
|
441
477
|
|
|
442
478
|
const onPaste = useCallback((e: ClipboardEvent<HTMLInputElement>) => {
|
|
443
|
-
if (disabledRef.current) return
|
|
479
|
+
if (disabledRef.current || readOnlyRef.current) return
|
|
444
480
|
e.preventDefault()
|
|
445
481
|
const text = e.clipboardData.getData('text')
|
|
446
482
|
const pos = inputRef.current?.selectionStart ?? 0
|
|
@@ -525,6 +561,7 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
525
561
|
spellCheck: false,
|
|
526
562
|
autoCorrect: 'off',
|
|
527
563
|
autoCapitalize: 'off',
|
|
564
|
+
...(readOnlyProp ? { 'aria-readonly': 'true' as const } : {}),
|
|
528
565
|
onKeyDown,
|
|
529
566
|
onChange,
|
|
530
567
|
onPaste,
|
|
@@ -532,6 +569,13 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
532
569
|
onBlur,
|
|
533
570
|
}
|
|
534
571
|
|
|
572
|
+
const wrapperProps: Record<string, string | undefined> = {
|
|
573
|
+
...(state.isComplete ? { 'data-complete': '' } : {}),
|
|
574
|
+
...(state.hasError ? { 'data-invalid': '' } : {}),
|
|
575
|
+
...(disabled ? { 'data-disabled': '' } : {}),
|
|
576
|
+
...(readOnlyProp ? { 'data-readonly': '' } : {}),
|
|
577
|
+
}
|
|
578
|
+
|
|
535
579
|
return {
|
|
536
580
|
slotValues: state.slotValues,
|
|
537
581
|
activeSlot: state.activeSlot,
|
|
@@ -548,6 +592,7 @@ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
|
|
|
548
592
|
separator,
|
|
549
593
|
hiddenInputProps,
|
|
550
594
|
getSlotProps,
|
|
595
|
+
wrapperProps,
|
|
551
596
|
}
|
|
552
597
|
}
|
|
553
598
|
|
package/src/adapters/svelte.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @license MIT
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { writable, derived, get } from 'svelte/store'
|
|
10
|
+
import { writable, derived, get, type Readable, type Writable } from 'svelte/store'
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
createDigito,
|
|
@@ -74,32 +74,34 @@ export type SvelteOTPOptions = DigitoOptions & {
|
|
|
74
74
|
|
|
75
75
|
export type UseOTPResult = {
|
|
76
76
|
/** Subscribe to the full state store. */
|
|
77
|
-
subscribe:
|
|
77
|
+
subscribe: Writable<DigitoState>['subscribe']
|
|
78
78
|
/** Derived — joined code string. */
|
|
79
|
-
value:
|
|
79
|
+
value: Readable<string>
|
|
80
80
|
/** Derived — completion boolean. */
|
|
81
|
-
isComplete:
|
|
81
|
+
isComplete: Readable<boolean>
|
|
82
82
|
/** Derived — error boolean. */
|
|
83
|
-
hasError:
|
|
83
|
+
hasError: Readable<boolean>
|
|
84
84
|
/** Derived — active slot index. */
|
|
85
|
-
activeSlot:
|
|
85
|
+
activeSlot: Readable<number>
|
|
86
86
|
/** Remaining timer seconds store. */
|
|
87
|
-
timerSeconds:
|
|
87
|
+
timerSeconds: Writable<number>
|
|
88
88
|
/** Whether the field is currently disabled. */
|
|
89
|
-
isDisabled:
|
|
90
|
-
/** The separator slot index store.
|
|
91
|
-
separatorAfter:
|
|
89
|
+
isDisabled: Writable<boolean>
|
|
90
|
+
/** The separator slot index store. */
|
|
91
|
+
separatorAfter: Writable<number | number[]>
|
|
92
92
|
/** The separator character store. */
|
|
93
|
-
separator:
|
|
93
|
+
separator: Writable<string>
|
|
94
94
|
/** Whether masked mode is active. When true, templates should render `maskChar` instead of char. */
|
|
95
|
-
masked:
|
|
95
|
+
masked: Writable<boolean>
|
|
96
96
|
/**
|
|
97
97
|
* The configured mask glyph store. Use in templates instead of a hard-coded `●`:
|
|
98
98
|
* `{$otp.masked && char ? $otp.maskChar : char}`
|
|
99
99
|
*/
|
|
100
|
-
maskChar:
|
|
100
|
+
maskChar: Writable<string>
|
|
101
101
|
/** The placeholder character for empty slots. Empty string when not set. */
|
|
102
102
|
placeholder: string
|
|
103
|
+
/** Derived — spread onto the wrapper element as data attributes for CSS/Tailwind targeting. */
|
|
104
|
+
wrapperAttrs: Readable<Record<string, string | undefined>>
|
|
103
105
|
/** Svelte action to bind to the single hidden input. */
|
|
104
106
|
action: (node: HTMLInputElement) => { destroy: () => void }
|
|
105
107
|
/** Returns the current joined code string. */
|
|
@@ -170,6 +172,8 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
170
172
|
pasteTransformer,
|
|
171
173
|
onInvalidChar,
|
|
172
174
|
value: controlledValue,
|
|
175
|
+
defaultValue,
|
|
176
|
+
readOnly: readOnlyOpt = false,
|
|
173
177
|
onChange: onChangeProp,
|
|
174
178
|
onFocus: onFocusProp,
|
|
175
179
|
onBlur: onBlurProp,
|
|
@@ -185,7 +189,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
185
189
|
} = options
|
|
186
190
|
|
|
187
191
|
// ── Core instance ──────────────────────────────────────────────────────────
|
|
188
|
-
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
|
|
192
|
+
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound, readOnly: readOnlyOpt })
|
|
189
193
|
|
|
190
194
|
// ── Stores ─────────────────────────────────────────────────────────────────
|
|
191
195
|
const store = writable(digito.state)
|
|
@@ -232,6 +236,14 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
232
236
|
|
|
233
237
|
if (controlledValue !== undefined) {
|
|
234
238
|
setValue(controlledValue)
|
|
239
|
+
} else if (defaultValue) {
|
|
240
|
+
// Apply defaultValue once — no onComplete, no onChange
|
|
241
|
+
const filtered = filterString(defaultValue.slice(0, length), type, pattern)
|
|
242
|
+
if (filtered) {
|
|
243
|
+
for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
|
|
244
|
+
digito.cancelPendingComplete()
|
|
245
|
+
sync(true)
|
|
246
|
+
}
|
|
235
247
|
}
|
|
236
248
|
|
|
237
249
|
// ── Timer ──────────────────────────────────────────────────────────────────
|
|
@@ -259,6 +271,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
259
271
|
node.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
|
|
260
272
|
node.setAttribute('autocorrect', 'off')
|
|
261
273
|
node.setAttribute('autocapitalize', 'off')
|
|
274
|
+
if (readOnlyOpt) node.setAttribute('aria-readonly', 'true')
|
|
262
275
|
|
|
263
276
|
const unsubDisabled = isDisabledStore.subscribe((v: boolean) => { node.disabled = v })
|
|
264
277
|
|
|
@@ -267,10 +280,17 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
267
280
|
const pos = node.selectionStart ?? 0
|
|
268
281
|
if (e.key === 'Backspace') {
|
|
269
282
|
e.preventDefault()
|
|
283
|
+
if (readOnlyOpt) return
|
|
270
284
|
digito.deleteChar(pos)
|
|
271
285
|
sync()
|
|
272
286
|
const next = digito.state.activeSlot
|
|
273
287
|
requestAnimationFrame(() => node.setSelectionRange(next, next))
|
|
288
|
+
} else if (e.key === 'Delete') {
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
if (readOnlyOpt) return
|
|
291
|
+
digito.clearSlot(pos)
|
|
292
|
+
sync()
|
|
293
|
+
requestAnimationFrame(() => node.setSelectionRange(pos, pos))
|
|
274
294
|
} else if (e.key === 'ArrowLeft') {
|
|
275
295
|
e.preventDefault()
|
|
276
296
|
digito.moveFocusLeft(pos)
|
|
@@ -301,7 +321,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
function onChange(e: Event): void {
|
|
304
|
-
if (get(isDisabledStore)) return
|
|
324
|
+
if (get(isDisabledStore) || readOnlyOpt) return
|
|
305
325
|
const raw = (e.target as HTMLInputElement).value
|
|
306
326
|
if (!raw) {
|
|
307
327
|
digito.resetState()
|
|
@@ -324,7 +344,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
324
344
|
}
|
|
325
345
|
|
|
326
346
|
function onPaste(e: ClipboardEvent): void {
|
|
327
|
-
if (get(isDisabledStore)) return
|
|
347
|
+
if (get(isDisabledStore) || readOnlyOpt) return
|
|
328
348
|
e.preventDefault()
|
|
329
349
|
const text = e.clipboardData?.getData('text') ?? ''
|
|
330
350
|
const pos = node.selectionStart ?? 0
|
|
@@ -420,6 +440,17 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
420
440
|
const hasError = derived(store, ($s: DigitoState) => $s.hasError)
|
|
421
441
|
const activeSlot = derived(store, ($s: DigitoState) => $s.activeSlot)
|
|
422
442
|
|
|
443
|
+
// Derived wrapper data attributes for CSS/Tailwind targeting
|
|
444
|
+
const wrapperAttrs = derived(
|
|
445
|
+
[store, isDisabledStore],
|
|
446
|
+
([$s, $dis]: [DigitoState, boolean]) => ({
|
|
447
|
+
...($s.isComplete ? { 'data-complete': '' } : {}),
|
|
448
|
+
...($s.hasError ? { 'data-invalid': '' } : {}),
|
|
449
|
+
...($dis ? { 'data-disabled': '' } : {}),
|
|
450
|
+
...(readOnlyOpt ? { 'data-readonly': '' } : {}),
|
|
451
|
+
})
|
|
452
|
+
)
|
|
453
|
+
|
|
423
454
|
return {
|
|
424
455
|
subscribe: store.subscribe,
|
|
425
456
|
value,
|
|
@@ -433,6 +464,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
433
464
|
masked: maskedStore,
|
|
434
465
|
maskChar: maskCharStore,
|
|
435
466
|
placeholder: placeholderOpt,
|
|
467
|
+
wrapperAttrs,
|
|
436
468
|
action,
|
|
437
469
|
getCode,
|
|
438
470
|
reset,
|
package/src/adapters/vanilla.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
createDigito,
|
|
37
37
|
createTimer,
|
|
38
38
|
filterString,
|
|
39
|
+
formatCountdown,
|
|
39
40
|
type DigitoOptions,
|
|
40
41
|
type InputType,
|
|
41
42
|
} from '../core/index.js'
|
|
@@ -89,7 +90,7 @@ const INJECTED_STYLE_ID = 'digito-styles'
|
|
|
89
90
|
* --digito-bg-filled Slot background when filled (default: #FFFFFF)
|
|
90
91
|
* --digito-color Digit text colour (default: #0A0A0A)
|
|
91
92
|
* --digito-border-color Default slot border (default: #E5E5E5)
|
|
92
|
-
* --digito-active-color Active slot border + ring (default: #
|
|
93
|
+
* --digito-active-color Active slot border + ring (default: #3D3D3D)
|
|
93
94
|
* --digito-error-color Error border, ring + badge (default: #FB2C36)
|
|
94
95
|
* --digito-success-color Success border + ring (default: #00C950)
|
|
95
96
|
* --digito-timer-color Timer label text colour (default: #5C5C5C)
|
|
@@ -237,6 +238,8 @@ function mountOnWrapper(
|
|
|
237
238
|
const onExpire = options.onExpire
|
|
238
239
|
const pattern = options.pattern
|
|
239
240
|
let isDisabled = options.disabled ?? false
|
|
241
|
+
const isReadOnly = options.readOnly ?? false
|
|
242
|
+
const defaultValue = options.defaultValue ?? ''
|
|
240
243
|
|
|
241
244
|
// New options
|
|
242
245
|
const autoFocus = options.autoFocus !== false // default true
|
|
@@ -275,6 +278,7 @@ function mountOnWrapper(
|
|
|
275
278
|
sound: options.sound ?? false,
|
|
276
279
|
haptic: options.haptic ?? true,
|
|
277
280
|
disabled: isDisabled,
|
|
281
|
+
readOnly: isReadOnly,
|
|
278
282
|
})
|
|
279
283
|
|
|
280
284
|
// ── Build DOM ────────────────────────────────────────────────────────────
|
|
@@ -330,6 +334,18 @@ function mountOnWrapper(
|
|
|
330
334
|
rootEl.appendChild(hiddenInputEl)
|
|
331
335
|
wrapperEl.appendChild(rootEl)
|
|
332
336
|
|
|
337
|
+
if (isReadOnly) hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
338
|
+
|
|
339
|
+
// Apply defaultValue once on mount — no onComplete, no onChange
|
|
340
|
+
if (defaultValue) {
|
|
341
|
+
const filtered = filterString(defaultValue.slice(0, slotCount), inputType, pattern)
|
|
342
|
+
if (filtered) {
|
|
343
|
+
for (let i = 0; i < filtered.length; i++) otpCore.inputChar(i, filtered[i])
|
|
344
|
+
otpCore.cancelPendingComplete()
|
|
345
|
+
hiddenInputEl.value = filtered
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
333
349
|
// ── Password manager badge guard ─────────────────────────────────────────
|
|
334
350
|
let disconnectPasswordManagerWatch: () => void = () => {}
|
|
335
351
|
requestAnimationFrame(() => {
|
|
@@ -487,6 +503,12 @@ function mountOnWrapper(
|
|
|
487
503
|
// resets selectionStart/End in some browsers, clobbering the cursor.
|
|
488
504
|
const newValue = slotValues.join('')
|
|
489
505
|
if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
|
|
506
|
+
|
|
507
|
+
// Expose component state as data attributes for CSS/Tailwind targeting
|
|
508
|
+
wrapperEl.toggleAttribute('data-complete', otpCore.state.isComplete)
|
|
509
|
+
wrapperEl.toggleAttribute('data-invalid', otpCore.state.hasError)
|
|
510
|
+
wrapperEl.toggleAttribute('data-disabled', isDisabled)
|
|
511
|
+
wrapperEl.toggleAttribute('data-readonly', isReadOnly)
|
|
490
512
|
}
|
|
491
513
|
|
|
492
514
|
// ── Event handlers ────────────────────────────────────────────────────────
|
|
@@ -496,9 +518,16 @@ function mountOnWrapper(
|
|
|
496
518
|
|
|
497
519
|
if (event.key === 'Backspace') {
|
|
498
520
|
event.preventDefault()
|
|
521
|
+
if (isReadOnly) return
|
|
499
522
|
otpCore.deleteChar(cursorPos)
|
|
500
523
|
syncSlotsToDOM()
|
|
501
524
|
hiddenInputEl.setSelectionRange(otpCore.state.activeSlot, otpCore.state.activeSlot)
|
|
525
|
+
} else if (event.key === 'Delete') {
|
|
526
|
+
event.preventDefault()
|
|
527
|
+
if (isReadOnly) return
|
|
528
|
+
otpCore.clearSlot(cursorPos)
|
|
529
|
+
syncSlotsToDOM()
|
|
530
|
+
hiddenInputEl.setSelectionRange(cursorPos, cursorPos)
|
|
502
531
|
} else if (event.key === 'Tab') {
|
|
503
532
|
if (event.shiftKey) {
|
|
504
533
|
// Shift+Tab: move to previous slot, or exit to previous DOM element from first slot
|
|
@@ -532,6 +561,7 @@ function mountOnWrapper(
|
|
|
532
561
|
}
|
|
533
562
|
|
|
534
563
|
function onHiddenInputChange(_event: Event): void {
|
|
564
|
+
if (isReadOnly) return
|
|
535
565
|
const rawValue = hiddenInputEl.value
|
|
536
566
|
|
|
537
567
|
if (!rawValue) {
|
|
@@ -567,6 +597,7 @@ function mountOnWrapper(
|
|
|
567
597
|
|
|
568
598
|
function onHiddenInputPaste(event: ClipboardEvent): void {
|
|
569
599
|
event.preventDefault()
|
|
600
|
+
if (isReadOnly) return
|
|
570
601
|
const pastedText = event.clipboardData?.getData('text') ?? ''
|
|
571
602
|
const cursorPos = hiddenInputEl.selectionStart ?? 0
|
|
572
603
|
otpCore.pasteString(cursorPos, pastedText)
|
|
@@ -716,19 +747,6 @@ function mountOnWrapper(
|
|
|
716
747
|
}
|
|
717
748
|
|
|
718
749
|
|
|
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
750
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
733
751
|
// PASSWORD MANAGER BADGE GUARD
|
|
734
752
|
// ─────────────────────────────────────────────────────────────────────────────
|