digitojs 1.0.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 (73) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +753 -0
  4. package/dist/adapters/alpine.d.ts +71 -0
  5. package/dist/adapters/alpine.d.ts.map +1 -0
  6. package/dist/adapters/alpine.js +560 -0
  7. package/dist/adapters/alpine.js.map +1 -0
  8. package/dist/adapters/react.d.ts +223 -0
  9. package/dist/adapters/react.d.ts.map +1 -0
  10. package/dist/adapters/react.js +337 -0
  11. package/dist/adapters/react.js.map +1 -0
  12. package/dist/adapters/svelte.d.ts +139 -0
  13. package/dist/adapters/svelte.d.ts.map +1 -0
  14. package/dist/adapters/svelte.js +295 -0
  15. package/dist/adapters/svelte.js.map +1 -0
  16. package/dist/adapters/vanilla.d.ts +110 -0
  17. package/dist/adapters/vanilla.d.ts.map +1 -0
  18. package/dist/adapters/vanilla.js +650 -0
  19. package/dist/adapters/vanilla.js.map +1 -0
  20. package/dist/adapters/vue.d.ts +163 -0
  21. package/dist/adapters/vue.d.ts.map +1 -0
  22. package/dist/adapters/vue.js +298 -0
  23. package/dist/adapters/vue.js.map +1 -0
  24. package/dist/adapters/web-component.d.ts +192 -0
  25. package/dist/adapters/web-component.d.ts.map +1 -0
  26. package/dist/adapters/web-component.js +832 -0
  27. package/dist/adapters/web-component.js.map +1 -0
  28. package/dist/core/feedback.d.ts +26 -0
  29. package/dist/core/feedback.d.ts.map +1 -0
  30. package/dist/core/feedback.js +47 -0
  31. package/dist/core/feedback.js.map +1 -0
  32. package/dist/core/filter.d.ts +24 -0
  33. package/dist/core/filter.d.ts.map +1 -0
  34. package/dist/core/filter.js +47 -0
  35. package/dist/core/filter.js.map +1 -0
  36. package/dist/core/index.d.ts +16 -0
  37. package/dist/core/index.d.ts.map +1 -0
  38. package/dist/core/index.js +15 -0
  39. package/dist/core/index.js.map +1 -0
  40. package/dist/core/machine.d.ts +67 -0
  41. package/dist/core/machine.d.ts.map +1 -0
  42. package/dist/core/machine.js +328 -0
  43. package/dist/core/machine.js.map +1 -0
  44. package/dist/core/timer.d.ts +24 -0
  45. package/dist/core/timer.d.ts.map +1 -0
  46. package/dist/core/timer.js +67 -0
  47. package/dist/core/timer.js.map +1 -0
  48. package/dist/core/types.d.ts +162 -0
  49. package/dist/core/types.d.ts.map +1 -0
  50. package/dist/core/types.js +10 -0
  51. package/dist/core/types.js.map +1 -0
  52. package/dist/digito-wc.min.js +254 -0
  53. package/dist/digito-wc.min.js.map +7 -0
  54. package/dist/digito.min.js +91 -0
  55. package/dist/digito.min.js.map +7 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +25 -0
  59. package/dist/index.js.map +1 -0
  60. package/package.json +109 -0
  61. package/src/adapters/alpine.ts +666 -0
  62. package/src/adapters/react.tsx +603 -0
  63. package/src/adapters/svelte.ts +444 -0
  64. package/src/adapters/vanilla.ts +810 -0
  65. package/src/adapters/vue.ts +462 -0
  66. package/src/adapters/web-component.ts +858 -0
  67. package/src/core/feedback.ts +44 -0
  68. package/src/core/filter.ts +48 -0
  69. package/src/core/index.ts +16 -0
  70. package/src/core/machine.ts +373 -0
  71. package/src/core/timer.ts +75 -0
  72. package/src/core/types.ts +167 -0
  73. package/src/index.ts +51 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * digito/react
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * React adapter — useOTP hook + HiddenOTPInput component (single hidden-input architecture)
5
+ *
6
+ * @author Olawale Balo — Product Designer + Design Engineer
7
+ * @license MIT
8
+ */
9
+
10
+ import {
11
+ useState,
12
+ useEffect,
13
+ useRef,
14
+ useCallback,
15
+ forwardRef,
16
+ type RefObject,
17
+ type KeyboardEvent,
18
+ type ChangeEvent,
19
+ type ClipboardEvent,
20
+ type CSSProperties,
21
+ } from 'react'
22
+
23
+ import {
24
+ createDigito,
25
+ createTimer,
26
+ filterString,
27
+ type DigitoOptions,
28
+ type DigitoState,
29
+ type InputType,
30
+ } from '../core/index.js'
31
+
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ // TYPES
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Extended options for the React useOTP hook.
39
+ * Adds controlled-input, separator, and disabled support on top of DigitoOptions.
40
+ *
41
+ * Controlled pattern (react-hook-form compatible):
42
+ * Pass value + onChange together. onChange fires exactly once per user
43
+ * interaction with the current joined code string (partial or complete).
44
+ *
45
+ * @example — uncontrolled (most common)
46
+ * const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
47
+ *
48
+ * @example — controlled / react-hook-form
49
+ * const { control } = useForm()
50
+ * <Controller name="otp" control={control} render={({ field }) => (
51
+ * <OTPInput value={field.value} onChange={field.onChange} length={6} />
52
+ * )} />
53
+ *
54
+ * @example — disabled during async verification
55
+ * const otp = useOTP({ length: 6, disabled: isVerifying })
56
+ */
57
+ export type ReactOTPOptions = DigitoOptions & {
58
+ /**
59
+ * Controlled value — drives the slot state from outside the hook.
60
+ * Pass a string of up to length characters to pre-fill or sync the field.
61
+ * Compatible with react-hook-form via <Controller>.
62
+ */
63
+ value?: string
64
+ /**
65
+ * Fires exactly ONCE per user interaction with the current joined code string.
66
+ * Receives partial values too — not just when the code is complete.
67
+ * Use alongside value for a fully controlled pattern.
68
+ */
69
+ onChange?: (code: string) => void
70
+ /**
71
+ * Insert a purely visual separator after this slot index (0-based).
72
+ * Accepts a single position or an array for multiple separators.
73
+ * aria-hidden, never part of the value, no effect on the state machine.
74
+ * Default: 0 (no separator).
75
+ * @example separatorAfter: 3 -> [*][*][*] — [*][*][*]
76
+ * @example separatorAfter: [2, 4] -> [*][*] — [*][*] — [*][*]
77
+ */
78
+ separatorAfter?: number | number[]
79
+ /**
80
+ * The character or string to render as the separator.
81
+ * Default: '—'
82
+ */
83
+ separator?: string
84
+ /**
85
+ * When `true`, each filled slot should display a mask glyph instead of the
86
+ * real character. The hidden input switches to `type="password"` for correct
87
+ * mobile keyboard and browser autocomplete behavior.
88
+ *
89
+ * `getCode()` and `onComplete` always return real characters — masking is visual only.
90
+ * Use for PIN entry or any sensitive input flow.
91
+ *
92
+ * Default: `false`.
93
+ */
94
+ masked?: boolean
95
+ /**
96
+ * The glyph displayed in filled slots when `masked` is `true`.
97
+ * Passed through `SlotRenderProps.maskChar` so headless slot components can
98
+ * render the correct character without needing to re-read the option.
99
+ *
100
+ * Default: `'●'` (U+25CF BLACK CIRCLE).
101
+ * @example maskChar: '*'
102
+ */
103
+ maskChar?: string
104
+ }
105
+
106
+ /**
107
+ * Per-slot render props returned by getSlotProps(index).
108
+ * Spread onto a custom slot component for full structural control.
109
+ *
110
+ * @example
111
+ * ```tsx
112
+ * function MySlot(props: SlotRenderProps) {
113
+ * return (
114
+ * <div className={['slot', props.isActive ? 'active' : ''].join(' ')}>
115
+ * {props.hasFakeCaret && <span className="caret" />}
116
+ * {props.char || <span className="placeholder">·</span>}
117
+ * </div>
118
+ * )
119
+ * }
120
+ *
121
+ * // In JSX:
122
+ * {otp.slotValues.map((_, i) => <MySlot key={i} {...otp.getSlotProps(i)} />)}
123
+ * ```
124
+ */
125
+ export type SlotRenderProps = {
126
+ /** The character value of this slot. Empty string when unfilled. */
127
+ char: string
128
+ /** Zero-based slot index. */
129
+ index: number
130
+ /** Whether this slot is the active/focused slot. */
131
+ isActive: boolean
132
+ /** Whether this slot contains a character. */
133
+ isFilled: boolean
134
+ /** Whether the field is in error state. */
135
+ isError: boolean
136
+ /** Whether all slots are filled. */
137
+ isComplete: boolean
138
+ /** Whether the field is disabled. */
139
+ isDisabled: boolean
140
+ /** Whether the hidden input currently has browser focus. */
141
+ isFocused: boolean
142
+ /**
143
+ * True when this slot is active, empty, and the hidden input has focus —
144
+ * i.e. the fake blinking caret should be rendered in this slot.
145
+ * Equivalent to: isActive && !isFilled && isFocused
146
+ */
147
+ hasFakeCaret: boolean
148
+ /**
149
+ * True when the `masked` option is enabled.
150
+ * When true, render `maskChar` instead of `char` for filled slots.
151
+ * `char` always holds the real character regardless of this flag.
152
+ */
153
+ masked: boolean
154
+ /**
155
+ * The configured mask glyph (from the `maskChar` option).
156
+ * Render this instead of `char` when `masked && isFilled`.
157
+ * @example {props.masked && props.isFilled ? props.maskChar : props.char}
158
+ */
159
+ maskChar: string
160
+ /**
161
+ * The placeholder character for empty slots (from the `placeholder` option).
162
+ * Render this when `!isFilled` to show a hint glyph such as `'○'` or `'_'`.
163
+ * Empty string when the option is not set.
164
+ */
165
+ placeholder: string
166
+ }
167
+
168
+ /** Props to spread onto the single hidden input element. */
169
+ export type HiddenInputProps = {
170
+ ref: RefObject<HTMLInputElement>
171
+ type: 'text' | 'password'
172
+ inputMode: 'numeric' | 'text'
173
+ autoComplete: 'one-time-code'
174
+ maxLength: number
175
+ disabled: boolean
176
+ /** The `name` attribute for native form submission / FormData. */
177
+ name?: string
178
+ /** Whether the input auto-focuses on mount. */
179
+ autoFocus?: boolean
180
+ 'aria-label': string
181
+ spellCheck: false
182
+ autoCorrect: 'off'
183
+ autoCapitalize: 'off'
184
+ onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void
185
+ onChange: (e: ChangeEvent<HTMLInputElement>) => void
186
+ onPaste: (e: ClipboardEvent<HTMLInputElement>) => void
187
+ onFocus: () => void
188
+ onBlur: () => void
189
+ }
190
+
191
+ export type UseOTPResult = {
192
+ /** Current value of each slot. Empty string = unfilled. */
193
+ slotValues: string[]
194
+ /** Index of the currently active slot. */
195
+ activeSlot: number
196
+ /** True when every slot is filled. */
197
+ isComplete: boolean
198
+ /** True when error state is active. */
199
+ hasError: boolean
200
+ /** True when the field is disabled. Mirrors the disabled option. */
201
+ isDisabled: boolean
202
+ /** Remaining timer seconds. 0 when expired or no timer configured. */
203
+ timerSeconds: number
204
+ /** True while the hidden input has browser focus. */
205
+ isFocused: boolean
206
+ /** Returns the current joined code string. */
207
+ getCode: () => string
208
+ /** Clear all slots, restart timer, return focus to input. */
209
+ reset: () => void
210
+ /** Apply or clear the error state. */
211
+ setError: (isError: boolean) => void
212
+ /** Programmatically move focus to a specific slot index. */
213
+ focus: (slotIndex: number) => void
214
+ /**
215
+ * The separator slot index/indices for JSX rendering.
216
+ * Insert a visual divider AFTER each position. `0` / empty array = no separator.
217
+ */
218
+ separatorAfter: number | number[]
219
+ /** The separator character/string to render. */
220
+ separator: string
221
+ /** Spread onto the single hidden input element. */
222
+ hiddenInputProps: HiddenInputProps
223
+ /**
224
+ * Returns render props for a single slot — spread onto a custom slot component
225
+ * for full structural control over the slot markup.
226
+ * @example
227
+ * ```tsx
228
+ * {otp.slotValues.map((_, i) => <MySlot key={i} {...otp.getSlotProps(i)} />)}
229
+ * ```
230
+ */
231
+ getSlotProps: (index: number) => SlotRenderProps
232
+ }
233
+
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // HOOK
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * React hook for OTP input — single hidden-input architecture.
241
+ *
242
+ * You render the visual slot divs; the hook handles all state, focus, and events.
243
+ *
244
+ * @example
245
+ * ```tsx
246
+ * const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
247
+ *
248
+ * <div style={{ position: 'relative', display: 'inline-flex', gap: 8 }}>
249
+ * <HiddenOTPInput {...otp.hiddenInputProps} />
250
+ * {otp.slotValues.map((_, i) => (
251
+ * <MySlot key={i} {...otp.getSlotProps(i)} />
252
+ * ))}
253
+ * </div>
254
+ * ```
255
+ */
256
+ export function useOTP(options: ReactOTPOptions = {}): UseOTPResult {
257
+ const {
258
+ length = 6,
259
+ type = 'numeric' as InputType,
260
+ timer: timerSecs = 0,
261
+ disabled = false,
262
+ onComplete,
263
+ onExpire,
264
+ onResend,
265
+ haptic = true,
266
+ sound = false,
267
+ pattern,
268
+ pasteTransformer,
269
+ onInvalidChar,
270
+ value: controlledValue,
271
+ onChange: onChangeProp,
272
+ onFocus: onFocusProp,
273
+ onBlur: onBlurProp,
274
+ separatorAfter = 0,
275
+ separator = '—',
276
+ masked = false,
277
+ maskChar = '\u25CF',
278
+ autoFocus = true,
279
+ name: inputName,
280
+ placeholder = '',
281
+ selectOnFocus = false,
282
+ blurOnComplete = false,
283
+ } = options
284
+
285
+ // ── Stable callback refs ───────────────────────────────────────────────────
286
+ const onCompleteRef = useRef(onComplete)
287
+ const onExpireRef = useRef(onExpire)
288
+ const onResendRef = useRef(onResend)
289
+ const onChangeRef = useRef(onChangeProp)
290
+ const onFocusRef = useRef(onFocusProp)
291
+ const onBlurRef = useRef(onBlurProp)
292
+ const onInvalidCharRef = useRef(onInvalidChar)
293
+ // Keep pattern and pasteTransformer in refs so callbacks always use the latest
294
+ // value without needing to be recreated on every render.
295
+ const patternRef = useRef(pattern)
296
+ const pasteTransformerRef = useRef(pasteTransformer)
297
+ useEffect(() => { onCompleteRef.current = onComplete }, [onComplete])
298
+ useEffect(() => { onExpireRef.current = onExpire }, [onExpire])
299
+ useEffect(() => { onResendRef.current = onResend }, [onResend])
300
+ useEffect(() => { onChangeRef.current = onChangeProp }, [onChangeProp])
301
+ useEffect(() => { onFocusRef.current = onFocusProp }, [onFocusProp])
302
+ useEffect(() => { onBlurRef.current = onBlurProp }, [onBlurProp])
303
+ useEffect(() => { onInvalidCharRef.current = onInvalidChar }, [onInvalidChar])
304
+ useEffect(() => { patternRef.current = pattern }, [pattern])
305
+ useEffect(() => { pasteTransformerRef.current = pasteTransformer }, [pasteTransformer])
306
+
307
+ // ── Core instance ──────────────────────────────────────────────────────────
308
+ const digitoRef = useRef(
309
+ createDigito({
310
+ length, type, haptic, sound, pattern, pasteTransformer,
311
+ onComplete: (code) => onCompleteRef.current?.(code),
312
+ onExpire: () => onExpireRef.current?.(),
313
+ onResend: () => onResendRef.current?.(),
314
+ onInvalidChar: (char, index) => onInvalidCharRef.current?.(char, index),
315
+ })
316
+ )
317
+ const digito = digitoRef.current
318
+
319
+ // ── Disabled ref ───────────────────────────────────────────────────────────
320
+ const disabledRef = useRef(disabled)
321
+ useEffect(() => { disabledRef.current = disabled }, [disabled])
322
+
323
+ // ── State ──────────────────────────────────────────────────────────────────
324
+ const [state, setState] = useState<DigitoState>(digito.state)
325
+ const [timerSeconds, setTimer] = useState(timerSecs)
326
+ const [timerTrigger, setTimerTrigger] = useState(0)
327
+ const [isFocused, setIsFocused] = useState(false)
328
+ const inputRef = useRef<HTMLInputElement>(null)
329
+
330
+ // ── sync() ─────────────────────────────────────────────────────────────────
331
+ function sync(suppressOnChange = false): void {
332
+ const next = { ...digito.state }
333
+ setState(next)
334
+ if (!suppressOnChange) {
335
+ onChangeRef.current?.(next.slotValues.join(''))
336
+ }
337
+ }
338
+
339
+ // ── Controlled value sync ──────────────────────────────────────────────────
340
+ useEffect(() => {
341
+ if (controlledValue === undefined) return
342
+
343
+ const incoming = filterString(controlledValue.slice(0, length), type, pattern)
344
+ const current = digito.state.slotValues.join('')
345
+
346
+ if (incoming === current) return
347
+
348
+ digito.resetState()
349
+ for (let i = 0; i < incoming.length; i++) {
350
+ digito.inputChar(i, incoming[i])
351
+ }
352
+
353
+ digito.cancelPendingComplete()
354
+
355
+ setState({ ...digito.state })
356
+
357
+ if (inputRef.current) {
358
+ inputRef.current.value = incoming
359
+ inputRef.current.setSelectionRange(incoming.length, incoming.length)
360
+ }
361
+
362
+ onChangeRef.current?.(incoming)
363
+
364
+ // eslint-disable-next-line react-hooks/exhaustive-deps
365
+ }, [controlledValue, length])
366
+
367
+ // ── Timer ──────────────────────────────────────────────────────────────────
368
+ useEffect(() => {
369
+ if (!timerSecs) return
370
+ setTimer(timerSecs)
371
+ const t = createTimer({
372
+ totalSeconds: timerSecs,
373
+ onTick: (r) => setTimer(r),
374
+ onExpire: () => { setTimer(0); onExpireRef.current?.() },
375
+ })
376
+ t.start()
377
+ return () => t.stop()
378
+ }, [timerSecs, timerTrigger])
379
+
380
+ // ── Event handlers ─────────────────────────────────────────────────────────
381
+
382
+ // eslint-disable-next-line react-hooks/exhaustive-deps
383
+ const onKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
384
+ if (disabledRef.current) return
385
+ const pos = inputRef.current?.selectionStart ?? 0
386
+ if (e.key === 'Backspace') {
387
+ e.preventDefault()
388
+ digito.deleteChar(pos)
389
+ sync()
390
+ const next = digito.state.activeSlot
391
+ requestAnimationFrame(() => inputRef.current?.setSelectionRange(next, next))
392
+ } else if (e.key === 'ArrowLeft') {
393
+ e.preventDefault()
394
+ digito.moveFocusLeft(pos)
395
+ sync()
396
+ const next = digito.state.activeSlot
397
+ requestAnimationFrame(() => inputRef.current?.setSelectionRange(next, next))
398
+ } else if (e.key === 'ArrowRight') {
399
+ e.preventDefault()
400
+ digito.moveFocusRight(pos)
401
+ sync()
402
+ const next = digito.state.activeSlot
403
+ requestAnimationFrame(() => inputRef.current?.setSelectionRange(next, next))
404
+ } else if (e.key === 'Tab') {
405
+ if (e.shiftKey) {
406
+ if (pos === 0) return
407
+ e.preventDefault()
408
+ digito.moveFocusLeft(pos)
409
+ } else {
410
+ if (!digito.state.slotValues[pos]) return
411
+ if (pos >= length - 1) return
412
+ e.preventDefault()
413
+ digito.moveFocusRight(pos)
414
+ }
415
+ sync()
416
+ const next = digito.state.activeSlot
417
+ requestAnimationFrame(() => inputRef.current?.setSelectionRange(next, next))
418
+ }
419
+ }, [])
420
+
421
+ const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
422
+ if (disabledRef.current) return
423
+ const raw = e.target.value
424
+ if (!raw) {
425
+ digito.resetState()
426
+ if (inputRef.current) { inputRef.current.value = ''; inputRef.current.setSelectionRange(0, 0) }
427
+ sync()
428
+ return
429
+ }
430
+ const valid = filterString(raw, type, patternRef.current).slice(0, length)
431
+ digito.resetState()
432
+ for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
433
+ const next = Math.min(valid.length, length - 1)
434
+ if (inputRef.current) { inputRef.current.value = valid; inputRef.current.setSelectionRange(next, next) }
435
+ digito.moveFocusTo(next)
436
+ sync()
437
+ if (blurOnComplete && digito.state.isComplete) {
438
+ requestAnimationFrame(() => inputRef.current?.blur())
439
+ }
440
+ }, [type, length, blurOnComplete])
441
+
442
+ const onPaste = useCallback((e: ClipboardEvent<HTMLInputElement>) => {
443
+ if (disabledRef.current) return
444
+ e.preventDefault()
445
+ const text = e.clipboardData.getData('text')
446
+ const pos = inputRef.current?.selectionStart ?? 0
447
+ digito.pasteString(pos, text)
448
+ const { slotValues, activeSlot } = digito.state
449
+ if (inputRef.current) { inputRef.current.value = slotValues.join(''); inputRef.current.setSelectionRange(activeSlot, activeSlot) }
450
+ sync()
451
+ if (blurOnComplete && digito.state.isComplete) {
452
+ requestAnimationFrame(() => inputRef.current?.blur())
453
+ }
454
+ }, [blurOnComplete])
455
+
456
+ const onFocus = useCallback(() => {
457
+ setIsFocused(true)
458
+ onFocusRef.current?.()
459
+ const pos = digito.state.activeSlot
460
+ requestAnimationFrame(() => {
461
+ const char = digito.state.slotValues[pos]
462
+ if (selectOnFocus && char) {
463
+ inputRef.current?.setSelectionRange(pos, pos + 1)
464
+ } else {
465
+ inputRef.current?.setSelectionRange(pos, pos)
466
+ }
467
+ })
468
+ }, [selectOnFocus])
469
+
470
+ const onBlur = useCallback(() => {
471
+ setIsFocused(false)
472
+ onBlurRef.current?.()
473
+ }, [])
474
+
475
+ // ── Public API ─────────────────────────────────────────────────────────────
476
+
477
+ const reset = useCallback(() => {
478
+ digito.resetState()
479
+ if (inputRef.current) { inputRef.current.value = ''; inputRef.current.focus(); inputRef.current.setSelectionRange(0, 0) }
480
+ setTimer(timerSecs)
481
+ setTimerTrigger((n: number) => n + 1)
482
+ sync(true)
483
+ }, [timerSecs])
484
+
485
+ const setError = useCallback((isError: boolean) => { digito.setError(isError); sync() }, [])
486
+
487
+ const focus = useCallback((slotIndex: number) => {
488
+ digito.moveFocusTo(slotIndex)
489
+ inputRef.current?.focus()
490
+ requestAnimationFrame(() => inputRef.current?.setSelectionRange(slotIndex, slotIndex))
491
+ sync()
492
+ }, [])
493
+
494
+ const getCode = useCallback(() => digito.getCode(), [])
495
+
496
+ function getSlotProps(index: number): SlotRenderProps {
497
+ const char = state.slotValues[index] ?? ''
498
+ const isActive = index === state.activeSlot && isFocused
499
+ return {
500
+ char,
501
+ index,
502
+ isActive,
503
+ isFilled: char.length === 1,
504
+ isError: state.hasError,
505
+ isComplete: state.isComplete,
506
+ isDisabled: disabled,
507
+ isFocused,
508
+ hasFakeCaret: isActive && char.length === 0,
509
+ masked,
510
+ maskChar,
511
+ placeholder,
512
+ }
513
+ }
514
+
515
+ const hiddenInputProps: HiddenInputProps = {
516
+ ref: inputRef,
517
+ type: masked ? 'password' : 'text',
518
+ inputMode: type === 'numeric' ? 'numeric' : 'text',
519
+ autoComplete: 'one-time-code',
520
+ maxLength: length,
521
+ disabled,
522
+ ...(inputName ? { name: inputName } : {}),
523
+ ...(autoFocus ? { autoFocus: true } : {}),
524
+ 'aria-label': `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`,
525
+ spellCheck: false,
526
+ autoCorrect: 'off',
527
+ autoCapitalize: 'off',
528
+ onKeyDown,
529
+ onChange,
530
+ onPaste,
531
+ onFocus,
532
+ onBlur,
533
+ }
534
+
535
+ return {
536
+ slotValues: state.slotValues,
537
+ activeSlot: state.activeSlot,
538
+ isComplete: state.isComplete,
539
+ hasError: state.hasError,
540
+ isDisabled: disabled,
541
+ timerSeconds,
542
+ isFocused,
543
+ getCode,
544
+ reset,
545
+ setError,
546
+ focus,
547
+ separatorAfter,
548
+ separator,
549
+ hiddenInputProps,
550
+ getSlotProps,
551
+ }
552
+ }
553
+
554
+
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+ // HIDDEN OTP INPUT COMPONENT
557
+ // ─────────────────────────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * Convenience wrapper around the hidden real <input> element.
561
+ * Applies the correct absolute-positioning styles so it sits invisibly
562
+ * on top of the slot row and captures all keyboard input + native autofill.
563
+ *
564
+ * Forward the ref from useOTP's hiddenInputProps, then spread the rest:
565
+ *
566
+ * @example
567
+ * ```tsx
568
+ * const otp = useOTP({ length: 6 })
569
+ *
570
+ * <div style={{ position: 'relative', display: 'inline-flex', gap: 8 }}>
571
+ * <HiddenOTPInput {...otp.hiddenInputProps} />
572
+ * {otp.slotValues.map((_, i) => <Slot key={i} {...otp.getSlotProps(i)} />)}
573
+ * </div>
574
+ * ```
575
+ */
576
+ const HIDDEN_INPUT_STYLE: CSSProperties = {
577
+ position: 'absolute',
578
+ inset: 0,
579
+ width: '100%',
580
+ height: '100%',
581
+ opacity: 0,
582
+ border: 'none',
583
+ outline: 'none',
584
+ background: 'transparent',
585
+ color: 'transparent',
586
+ caretColor: 'transparent',
587
+ zIndex: 1,
588
+ cursor: 'text',
589
+ fontSize: 1,
590
+ }
591
+
592
+ export const HiddenOTPInput = forwardRef<
593
+ HTMLInputElement,
594
+ Omit<HiddenInputProps, 'ref'>
595
+ >((props, ref) => (
596
+ <input
597
+ ref={ref}
598
+ style={HIDDEN_INPUT_STYLE}
599
+ {...props}
600
+ />
601
+ ))
602
+
603
+ HiddenOTPInput.displayName = 'HiddenOTPInput'