digitojs 1.0.1 → 1.2.1
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 +129 -56
- package/dist/adapters/alpine.d.ts.map +1 -1
- package/dist/adapters/alpine.js +82 -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 +21 -14
- package/dist/adapters/svelte.d.ts.map +1 -1
- package/dist/adapters/svelte.js +50 -4
- package/dist/adapters/svelte.js.map +1 -1
- package/dist/adapters/vanilla.d.ts +6 -0
- package/dist/adapters/vanilla.d.ts.map +1 -1
- package/dist/adapters/vanilla.js +48 -13
- 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 +9 -0
- package/dist/adapters/web-component.d.ts.map +1 -1
- package/dist/adapters/web-component.js +55 -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 +3 -3
- package/dist/digito-wc.min.js.map +3 -3
- package/dist/digito.min.js +2 -2
- 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 +9 -3
- package/src/adapters/alpine.ts +75 -50
- package/src/adapters/react.tsx +50 -5
- package/src/adapters/svelte.ts +70 -17
- package/src/adapters/vanilla.ts +50 -15
- package/src/adapters/vue.ts +32 -3
- package/src/adapters/web-component.ts +53 -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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "digitojs",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Reliable OTP inputs for any framework from a single core.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"verification-code",
|
|
72
72
|
"one-time-password",
|
|
73
73
|
"authentication",
|
|
74
|
+
"headless",
|
|
74
75
|
"passcode",
|
|
75
76
|
"otp-field",
|
|
76
77
|
"react-otp",
|
|
@@ -85,6 +86,11 @@
|
|
|
85
86
|
"typescript"
|
|
86
87
|
],
|
|
87
88
|
"author": "Olawale Balo — Product Designer + Design Engineer",
|
|
89
|
+
"homepage": "https://digitojs.vercel.app",
|
|
90
|
+
"repository": {
|
|
91
|
+
"type": "git",
|
|
92
|
+
"url": "https://github.com/theolawalemi/digito"
|
|
93
|
+
},
|
|
88
94
|
"license": "MIT",
|
|
89
95
|
"peerDependencies": {
|
|
90
96
|
"react": ">=17",
|
|
@@ -106,4 +112,4 @@
|
|
|
106
112
|
"typescript": "^5.4.0"
|
|
107
113
|
},
|
|
108
114
|
"type": "module"
|
|
109
|
-
}
|
|
115
|
+
}
|
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,14 +159,17 @@ 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
|
|
172
|
+
let isReadOnly = readOnlyOpt
|
|
181
173
|
let successState = false
|
|
182
174
|
|
|
183
175
|
// ── Build DOM ─────────────────────────────────────────────────────────────
|
|
@@ -267,8 +259,19 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
267
259
|
hiddenInputEl.setAttribute('autocapitalize', 'off')
|
|
268
260
|
hiddenInputEl.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0;border:none;outline:none;background:transparent;color:transparent;caret-color:transparent;z-index:1;cursor:text;font-size:1px'
|
|
269
261
|
if (inputName) hiddenInputEl.name = inputName
|
|
262
|
+
if (readOnlyOpt) hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
270
263
|
wrapperEl.appendChild(hiddenInputEl)
|
|
271
264
|
|
|
265
|
+
// Apply defaultValue once on mount — no onComplete, no onChange
|
|
266
|
+
if (defaultValue) {
|
|
267
|
+
const filtered = filterString(defaultValue.slice(0, length), type, pattern)
|
|
268
|
+
if (filtered) {
|
|
269
|
+
for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
|
|
270
|
+
digito.cancelPendingComplete()
|
|
271
|
+
hiddenInputEl.value = filtered
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
272
275
|
// ── Built-in timer + resend (mirrors vanilla adapter) ──────────────────────
|
|
273
276
|
let timerBadgeEl: HTMLSpanElement | null = null
|
|
274
277
|
let resendActionBtn: HTMLButtonElement | null = null
|
|
@@ -427,6 +430,11 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
427
430
|
// resets selectionStart/End in some browsers, clobbering the cursor.
|
|
428
431
|
const newValue = slotValues.join('')
|
|
429
432
|
if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
|
|
433
|
+
|
|
434
|
+
wrapperEl.toggleAttribute('data-complete', digito.state.isComplete)
|
|
435
|
+
wrapperEl.toggleAttribute('data-invalid', digito.state.hasError)
|
|
436
|
+
wrapperEl.toggleAttribute('data-disabled', isDisabled)
|
|
437
|
+
wrapperEl.toggleAttribute('data-readonly', isReadOnly)
|
|
430
438
|
}
|
|
431
439
|
|
|
432
440
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
|
@@ -435,11 +443,19 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
435
443
|
const pos = hiddenInputEl.selectionStart ?? 0
|
|
436
444
|
if (e.key === 'Backspace') {
|
|
437
445
|
e.preventDefault()
|
|
446
|
+
if (isReadOnly) return
|
|
438
447
|
digito.deleteChar(pos)
|
|
439
448
|
syncSlotsToDOM()
|
|
440
449
|
onChangeProp?.(digito.getCode())
|
|
441
450
|
const next = digito.state.activeSlot
|
|
442
451
|
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
452
|
+
} else if (e.key === 'Delete') {
|
|
453
|
+
e.preventDefault()
|
|
454
|
+
if (isReadOnly) return
|
|
455
|
+
digito.clearSlot(pos)
|
|
456
|
+
syncSlotsToDOM()
|
|
457
|
+
onChangeProp?.(digito.getCode())
|
|
458
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(pos, pos))
|
|
443
459
|
} else if (e.key === 'ArrowLeft') {
|
|
444
460
|
e.preventDefault()
|
|
445
461
|
digito.moveFocusLeft(pos)
|
|
@@ -470,7 +486,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
470
486
|
})
|
|
471
487
|
|
|
472
488
|
hiddenInputEl.addEventListener('input', () => {
|
|
473
|
-
if (isDisabled) return
|
|
489
|
+
if (isDisabled || isReadOnly) return
|
|
474
490
|
const raw = hiddenInputEl.value
|
|
475
491
|
if (!raw) {
|
|
476
492
|
digito.resetState()
|
|
@@ -495,7 +511,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
495
511
|
})
|
|
496
512
|
|
|
497
513
|
hiddenInputEl.addEventListener('paste', (e) => {
|
|
498
|
-
if (isDisabled) return
|
|
514
|
+
if (isDisabled || isReadOnly) return
|
|
499
515
|
e.preventDefault()
|
|
500
516
|
const text = e.clipboardData?.getData('text') ?? ''
|
|
501
517
|
const pos = hiddenInputEl.selectionStart ?? 0
|
|
@@ -557,6 +573,30 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
557
573
|
syncSlotsToDOM()
|
|
558
574
|
})
|
|
559
575
|
|
|
576
|
+
// ── Internal helpers (shared by public API methods below) ─────────────────
|
|
577
|
+
|
|
578
|
+
/** Tears down running timers and removes built-in footer elements from the DOM. */
|
|
579
|
+
function teardown(): void {
|
|
580
|
+
mainCountdown?.stop()
|
|
581
|
+
resendCountdown?.stop()
|
|
582
|
+
builtInFooterEl?.remove()
|
|
583
|
+
builtInResendRowEl?.remove()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Resets slot state, restarts timers, and restores focus — shared by reset() and resend(). */
|
|
587
|
+
function doReset(): void {
|
|
588
|
+
digito.resetState()
|
|
589
|
+
hiddenInputEl.value = ''
|
|
590
|
+
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
591
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
592
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
593
|
+
resendCountdown?.stop()
|
|
594
|
+
mainCountdown?.restart()
|
|
595
|
+
if (!isDisabled) hiddenInputEl.focus()
|
|
596
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
597
|
+
syncSlotsToDOM()
|
|
598
|
+
}
|
|
599
|
+
|
|
560
600
|
// ── Public API on element ──────────────────────────────────────────────────
|
|
561
601
|
// Exposed on `el._digito` for programmatic control from Alpine components or
|
|
562
602
|
// external JavaScript. Mirrors the DigitoInstance interface from the vanilla adapter.
|
|
@@ -565,41 +605,13 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
565
605
|
getCode: () => digito.getCode(),
|
|
566
606
|
|
|
567
607
|
/** 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
|
-
},
|
|
608
|
+
destroy: () => teardown(),
|
|
574
609
|
|
|
575
610
|
/** 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
|
-
},
|
|
611
|
+
reset: () => doReset(),
|
|
588
612
|
|
|
589
613
|
/** 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
|
-
},
|
|
614
|
+
resend: () => { doReset(); onResend?.() },
|
|
603
615
|
|
|
604
616
|
/** Apply or clear the error state on all visual slots. */
|
|
605
617
|
setError: (isError: boolean) => {
|
|
@@ -639,6 +651,22 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
639
651
|
}
|
|
640
652
|
},
|
|
641
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Toggle readOnly at runtime. When `true`, all slot mutations are blocked
|
|
656
|
+
* but focus, navigation, and copy remain fully functional.
|
|
657
|
+
* Distinct from `disabled` — no opacity/cursor change, `aria-readonly` is set.
|
|
658
|
+
*/
|
|
659
|
+
setReadOnly: (value: boolean) => {
|
|
660
|
+
isReadOnly = value
|
|
661
|
+
digito.setReadOnly(value)
|
|
662
|
+
if (value) {
|
|
663
|
+
hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
664
|
+
} else {
|
|
665
|
+
hiddenInputEl.removeAttribute('aria-readonly')
|
|
666
|
+
}
|
|
667
|
+
syncSlotsToDOM()
|
|
668
|
+
},
|
|
669
|
+
|
|
642
670
|
/**
|
|
643
671
|
* Programmatically move focus to a specific slot index.
|
|
644
672
|
* Focuses the hidden input and positions the cursor at `slotIndex`.
|
|
@@ -655,10 +683,7 @@ export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
|
655
683
|
return {
|
|
656
684
|
/** Alpine calls this when the component is destroyed. Stops timers and removes footer elements. */
|
|
657
685
|
cleanup() {
|
|
658
|
-
|
|
659
|
-
resendCountdown?.stop()
|
|
660
|
-
builtInFooterEl?.remove()
|
|
661
|
-
builtInResendRowEl?.remove()
|
|
686
|
+
teardown()
|
|
662
687
|
digito.resetState()
|
|
663
688
|
},
|
|
664
689
|
}
|
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. */
|
|
@@ -110,6 +112,11 @@ export type UseOTPResult = {
|
|
|
110
112
|
setError: (isError: boolean) => void
|
|
111
113
|
/** Enable or disable the field at runtime. */
|
|
112
114
|
setDisabled: (value: boolean) => void
|
|
115
|
+
/**
|
|
116
|
+
* Toggle readOnly at runtime. When `true`, all slot mutations are blocked
|
|
117
|
+
* but focus, navigation, and copy remain fully functional.
|
|
118
|
+
*/
|
|
119
|
+
setReadOnly: (value: boolean) => void
|
|
113
120
|
/** Programmatically move focus to a slot index. */
|
|
114
121
|
focus: (slotIndex: number) => void
|
|
115
122
|
/**
|
|
@@ -170,6 +177,8 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
170
177
|
pasteTransformer,
|
|
171
178
|
onInvalidChar,
|
|
172
179
|
value: controlledValue,
|
|
180
|
+
defaultValue,
|
|
181
|
+
readOnly: readOnlyOpt = false,
|
|
173
182
|
onChange: onChangeProp,
|
|
174
183
|
onFocus: onFocusProp,
|
|
175
184
|
onBlur: onBlurProp,
|
|
@@ -185,18 +194,20 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
185
194
|
} = options
|
|
186
195
|
|
|
187
196
|
// ── Core instance ──────────────────────────────────────────────────────────
|
|
188
|
-
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
|
|
197
|
+
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound, readOnly: readOnlyOpt })
|
|
189
198
|
|
|
190
199
|
// ── Stores ─────────────────────────────────────────────────────────────────
|
|
191
200
|
const store = writable(digito.state)
|
|
192
201
|
const timerStore = writable(timerSecs)
|
|
193
202
|
const isDisabledStore = writable(initialDisabled)
|
|
203
|
+
const isReadOnlyStore = writable(readOnlyOpt)
|
|
194
204
|
const separatorAfterStore = writable(separatorAfterOpt)
|
|
195
205
|
const separatorStore = writable(separatorOpt)
|
|
196
206
|
const maskedStore = writable(maskedOpt)
|
|
197
207
|
const maskCharStore = writable(maskCharOpt)
|
|
198
208
|
|
|
199
|
-
let inputEl:
|
|
209
|
+
let inputEl: HTMLInputElement | null = null
|
|
210
|
+
let isReadOnly: boolean = readOnlyOpt
|
|
200
211
|
|
|
201
212
|
// ── sync() ─────────────────────────────────────────────────────────────────
|
|
202
213
|
function sync(suppressOnChange = false): void {
|
|
@@ -232,6 +243,14 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
232
243
|
|
|
233
244
|
if (controlledValue !== undefined) {
|
|
234
245
|
setValue(controlledValue)
|
|
246
|
+
} else if (defaultValue) {
|
|
247
|
+
// Apply defaultValue once — no onComplete, no onChange
|
|
248
|
+
const filtered = filterString(defaultValue.slice(0, length), type, pattern)
|
|
249
|
+
if (filtered) {
|
|
250
|
+
for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
|
|
251
|
+
digito.cancelPendingComplete()
|
|
252
|
+
sync(true)
|
|
253
|
+
}
|
|
235
254
|
}
|
|
236
255
|
|
|
237
256
|
// ── Timer ──────────────────────────────────────────────────────────────────
|
|
@@ -259,6 +278,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
259
278
|
node.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
|
|
260
279
|
node.setAttribute('autocorrect', 'off')
|
|
261
280
|
node.setAttribute('autocapitalize', 'off')
|
|
281
|
+
if (readOnlyOpt) node.setAttribute('aria-readonly', 'true')
|
|
262
282
|
|
|
263
283
|
const unsubDisabled = isDisabledStore.subscribe((v: boolean) => { node.disabled = v })
|
|
264
284
|
|
|
@@ -267,10 +287,17 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
267
287
|
const pos = node.selectionStart ?? 0
|
|
268
288
|
if (e.key === 'Backspace') {
|
|
269
289
|
e.preventDefault()
|
|
290
|
+
if (isReadOnly) return
|
|
270
291
|
digito.deleteChar(pos)
|
|
271
292
|
sync()
|
|
272
293
|
const next = digito.state.activeSlot
|
|
273
294
|
requestAnimationFrame(() => node.setSelectionRange(next, next))
|
|
295
|
+
} else if (e.key === 'Delete') {
|
|
296
|
+
e.preventDefault()
|
|
297
|
+
if (isReadOnly) return
|
|
298
|
+
digito.clearSlot(pos)
|
|
299
|
+
sync()
|
|
300
|
+
requestAnimationFrame(() => node.setSelectionRange(pos, pos))
|
|
274
301
|
} else if (e.key === 'ArrowLeft') {
|
|
275
302
|
e.preventDefault()
|
|
276
303
|
digito.moveFocusLeft(pos)
|
|
@@ -301,7 +328,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
function onChange(e: Event): void {
|
|
304
|
-
if (get(isDisabledStore)) return
|
|
331
|
+
if (get(isDisabledStore) || isReadOnly) return
|
|
305
332
|
const raw = (e.target as HTMLInputElement).value
|
|
306
333
|
if (!raw) {
|
|
307
334
|
digito.resetState()
|
|
@@ -324,7 +351,7 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
324
351
|
}
|
|
325
352
|
|
|
326
353
|
function onPaste(e: ClipboardEvent): void {
|
|
327
|
-
if (get(isDisabledStore)) return
|
|
354
|
+
if (get(isDisabledStore) || isReadOnly) return
|
|
328
355
|
e.preventDefault()
|
|
329
356
|
const text = e.clipboardData?.getData('text') ?? ''
|
|
330
357
|
const pos = node.selectionStart ?? 0
|
|
@@ -403,6 +430,19 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
403
430
|
digito.setDisabled(value)
|
|
404
431
|
}
|
|
405
432
|
|
|
433
|
+
function setReadOnly(value: boolean): void {
|
|
434
|
+
isReadOnly = value
|
|
435
|
+
isReadOnlyStore.set(value)
|
|
436
|
+
digito.setReadOnly(value)
|
|
437
|
+
if (inputEl) {
|
|
438
|
+
if (value) {
|
|
439
|
+
inputEl.setAttribute('aria-readonly', 'true')
|
|
440
|
+
} else {
|
|
441
|
+
inputEl.removeAttribute('aria-readonly')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
406
446
|
function focus(slotIndex: number): void {
|
|
407
447
|
digito.moveFocusTo(slotIndex)
|
|
408
448
|
inputEl?.focus()
|
|
@@ -420,6 +460,17 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
420
460
|
const hasError = derived(store, ($s: DigitoState) => $s.hasError)
|
|
421
461
|
const activeSlot = derived(store, ($s: DigitoState) => $s.activeSlot)
|
|
422
462
|
|
|
463
|
+
// Derived wrapper data attributes for CSS/Tailwind targeting
|
|
464
|
+
const wrapperAttrs = derived(
|
|
465
|
+
[store, isDisabledStore, isReadOnlyStore],
|
|
466
|
+
([$s, $dis, $ro]: [DigitoState, boolean, boolean]) => ({
|
|
467
|
+
...($s.isComplete ? { 'data-complete': '' } : {}),
|
|
468
|
+
...($s.hasError ? { 'data-invalid': '' } : {}),
|
|
469
|
+
...($dis ? { 'data-disabled': '' } : {}),
|
|
470
|
+
...($ro ? { 'data-readonly': '' } : {}),
|
|
471
|
+
})
|
|
472
|
+
)
|
|
473
|
+
|
|
423
474
|
return {
|
|
424
475
|
subscribe: store.subscribe,
|
|
425
476
|
value,
|
|
@@ -433,11 +484,13 @@ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
|
|
|
433
484
|
masked: maskedStore,
|
|
434
485
|
maskChar: maskCharStore,
|
|
435
486
|
placeholder: placeholderOpt,
|
|
487
|
+
wrapperAttrs,
|
|
436
488
|
action,
|
|
437
489
|
getCode,
|
|
438
490
|
reset,
|
|
439
491
|
setError,
|
|
440
492
|
setDisabled,
|
|
493
|
+
setReadOnly,
|
|
441
494
|
setValue,
|
|
442
495
|
focus,
|
|
443
496
|
}
|