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.
Files changed (59) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +129 -56
  3. package/dist/adapters/alpine.d.ts.map +1 -1
  4. package/dist/adapters/alpine.js +82 -58
  5. package/dist/adapters/alpine.js.map +1 -1
  6. package/dist/adapters/react.d.ts +9 -0
  7. package/dist/adapters/react.d.ts.map +1 -1
  8. package/dist/adapters/react.js +45 -4
  9. package/dist/adapters/react.js.map +1 -1
  10. package/dist/adapters/svelte.d.ts +21 -14
  11. package/dist/adapters/svelte.d.ts.map +1 -1
  12. package/dist/adapters/svelte.js +50 -4
  13. package/dist/adapters/svelte.js.map +1 -1
  14. package/dist/adapters/vanilla.d.ts +6 -0
  15. package/dist/adapters/vanilla.d.ts.map +1 -1
  16. package/dist/adapters/vanilla.js +48 -13
  17. package/dist/adapters/vanilla.js.map +1 -1
  18. package/dist/adapters/vue.d.ts +2 -0
  19. package/dist/adapters/vue.d.ts.map +1 -1
  20. package/dist/adapters/vue.js +35 -4
  21. package/dist/adapters/vue.js.map +1 -1
  22. package/dist/adapters/web-component.d.ts +9 -0
  23. package/dist/adapters/web-component.d.ts.map +1 -1
  24. package/dist/adapters/web-component.js +55 -12
  25. package/dist/adapters/web-component.js.map +1 -1
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +1 -1
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/machine.d.ts +3 -0
  31. package/dist/core/machine.d.ts.map +1 -1
  32. package/dist/core/machine.js +31 -3
  33. package/dist/core/machine.js.map +1 -1
  34. package/dist/core/timer.d.ts +8 -0
  35. package/dist/core/timer.d.ts.map +1 -1
  36. package/dist/core/timer.js +14 -0
  37. package/dist/core/timer.js.map +1 -1
  38. package/dist/core/types.d.ts +30 -2
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/digito-wc.min.js +3 -3
  41. package/dist/digito-wc.min.js.map +3 -3
  42. package/dist/digito.min.js +2 -2
  43. package/dist/digito.min.js.map +3 -3
  44. package/dist/index.d.ts +0 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +0 -1
  47. package/dist/index.js.map +1 -1
  48. package/package.json +9 -3
  49. package/src/adapters/alpine.ts +75 -50
  50. package/src/adapters/react.tsx +50 -5
  51. package/src/adapters/svelte.ts +70 -17
  52. package/src/adapters/vanilla.ts +50 -15
  53. package/src/adapters/vue.ts +32 -3
  54. package/src/adapters/web-component.ts +53 -13
  55. package/src/core/index.ts +1 -1
  56. package/src/core/machine.ts +31 -3
  57. package/src/core/timer.ts +15 -0
  58. package/src/core/types.ts +30 -2
  59. package/src/index.ts +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "digitojs",
3
- "version": "1.0.1",
4
- "description": "The only framework-agnostic OTP input state machine powering React, Vue, Svelte, Alpine, Vanilla JS, and Web Components from a single core.",
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
+ }
@@ -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
- mainCountdown?.stop()
659
- resendCountdown?.stop()
660
- builtInFooterEl?.remove()
661
- builtInResendRowEl?.remove()
686
+ teardown()
662
687
  digito.resetState()
663
688
  },
664
689
  }
@@ -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 ref ───────────────────────────────────────────────────────────
320
- const disabledRef = useRef(disabled)
321
- useEffect(() => { disabledRef.current = disabled }, [disabled])
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
 
@@ -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: ReturnType<typeof writable>['subscribe']
77
+ subscribe: Writable<DigitoState>['subscribe']
78
78
  /** Derived — joined code string. */
79
- value: ReturnType<typeof derived>
79
+ value: Readable<string>
80
80
  /** Derived — completion boolean. */
81
- isComplete: ReturnType<typeof derived>
81
+ isComplete: Readable<boolean>
82
82
  /** Derived — error boolean. */
83
- hasError: ReturnType<typeof derived>
83
+ hasError: Readable<boolean>
84
84
  /** Derived — active slot index. */
85
- activeSlot: ReturnType<typeof derived>
85
+ activeSlot: Readable<number>
86
86
  /** Remaining timer seconds store. */
87
- timerSeconds: ReturnType<typeof writable>
87
+ timerSeconds: Writable<number>
88
88
  /** Whether the field is currently disabled. */
89
- isDisabled: ReturnType<typeof writable>
90
- /** The separator slot index store. -1 = no separator. */
91
- separatorAfter: ReturnType<typeof writable>
89
+ isDisabled: Writable<boolean>
90
+ /** The separator slot index store. */
91
+ separatorAfter: Writable<number | number[]>
92
92
  /** The separator character store. */
93
- separator: ReturnType<typeof writable>
93
+ separator: Writable<string>
94
94
  /** Whether masked mode is active. When true, templates should render `maskChar` instead of char. */
95
- masked: ReturnType<typeof writable>
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: ReturnType<typeof writable>
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: HTMLInputElement | null = null
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
  }