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.
Files changed (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +114 -41
  3. package/dist/adapters/alpine.d.ts.map +1 -1
  4. package/dist/adapters/alpine.js +65 -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 +16 -14
  11. package/dist/adapters/svelte.d.ts.map +1 -1
  12. package/dist/adapters/svelte.js +34 -4
  13. package/dist/adapters/svelte.js.map +1 -1
  14. package/dist/adapters/vanilla.d.ts.map +1 -1
  15. package/dist/adapters/vanilla.js +36 -12
  16. package/dist/adapters/vanilla.js.map +1 -1
  17. package/dist/adapters/vue.d.ts +2 -0
  18. package/dist/adapters/vue.d.ts.map +1 -1
  19. package/dist/adapters/vue.js +35 -4
  20. package/dist/adapters/vue.js.map +1 -1
  21. package/dist/adapters/web-component.d.ts +3 -0
  22. package/dist/adapters/web-component.d.ts.map +1 -1
  23. package/dist/adapters/web-component.js +37 -12
  24. package/dist/adapters/web-component.js.map +1 -1
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/machine.d.ts +3 -0
  30. package/dist/core/machine.d.ts.map +1 -1
  31. package/dist/core/machine.js +31 -3
  32. package/dist/core/machine.js.map +1 -1
  33. package/dist/core/timer.d.ts +8 -0
  34. package/dist/core/timer.d.ts.map +1 -1
  35. package/dist/core/timer.js +14 -0
  36. package/dist/core/timer.js.map +1 -1
  37. package/dist/core/types.d.ts +30 -2
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/digito-wc.min.js +2 -2
  40. package/dist/digito-wc.min.js.map +3 -3
  41. package/dist/digito.min.js +1 -1
  42. package/dist/digito.min.js.map +3 -3
  43. package/dist/index.d.ts +0 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +0 -1
  46. package/dist/index.js.map +1 -1
  47. package/package.json +8 -2
  48. package/src/adapters/alpine.ts +58 -50
  49. package/src/adapters/react.tsx +50 -5
  50. package/src/adapters/svelte.ts +48 -16
  51. package/src/adapters/vanilla.ts +32 -14
  52. package/src/adapters/vue.ts +32 -3
  53. package/src/adapters/web-component.ts +35 -13
  54. package/src/core/index.ts +1 -1
  55. package/src/core/machine.ts +31 -3
  56. package/src/core/timer.ts +15 -0
  57. package/src/core/types.ts +30 -2
  58. package/src/index.ts +0 -1
@@ -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
- mainCountdown?.stop()
659
- resendCountdown?.stop()
660
- builtInFooterEl?.remove()
661
- builtInResendRowEl?.remove()
669
+ teardown()
662
670
  digito.resetState()
663
671
  },
664
672
  }
@@ -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. */
@@ -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,
@@ -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: #757575)
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
  // ─────────────────────────────────────────────────────────────────────────────