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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +753 -0
- package/dist/adapters/alpine.d.ts +71 -0
- package/dist/adapters/alpine.d.ts.map +1 -0
- package/dist/adapters/alpine.js +560 -0
- package/dist/adapters/alpine.js.map +1 -0
- package/dist/adapters/react.d.ts +223 -0
- package/dist/adapters/react.d.ts.map +1 -0
- package/dist/adapters/react.js +337 -0
- package/dist/adapters/react.js.map +1 -0
- package/dist/adapters/svelte.d.ts +139 -0
- package/dist/adapters/svelte.d.ts.map +1 -0
- package/dist/adapters/svelte.js +295 -0
- package/dist/adapters/svelte.js.map +1 -0
- package/dist/adapters/vanilla.d.ts +110 -0
- package/dist/adapters/vanilla.d.ts.map +1 -0
- package/dist/adapters/vanilla.js +650 -0
- package/dist/adapters/vanilla.js.map +1 -0
- package/dist/adapters/vue.d.ts +163 -0
- package/dist/adapters/vue.d.ts.map +1 -0
- package/dist/adapters/vue.js +298 -0
- package/dist/adapters/vue.js.map +1 -0
- package/dist/adapters/web-component.d.ts +192 -0
- package/dist/adapters/web-component.d.ts.map +1 -0
- package/dist/adapters/web-component.js +832 -0
- package/dist/adapters/web-component.js.map +1 -0
- package/dist/core/feedback.d.ts +26 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +47 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/filter.d.ts +24 -0
- package/dist/core/filter.d.ts.map +1 -0
- package/dist/core/filter.js +47 -0
- package/dist/core/filter.js.map +1 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +15 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/machine.d.ts +67 -0
- package/dist/core/machine.d.ts.map +1 -0
- package/dist/core/machine.js +328 -0
- package/dist/core/machine.js.map +1 -0
- package/dist/core/timer.d.ts +24 -0
- package/dist/core/timer.d.ts.map +1 -0
- package/dist/core/timer.js +67 -0
- package/dist/core/timer.js.map +1 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +10 -0
- package/dist/core/types.js.map +1 -0
- package/dist/digito-wc.min.js +254 -0
- package/dist/digito-wc.min.js.map +7 -0
- package/dist/digito.min.js +91 -0
- package/dist/digito.min.js.map +7 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/alpine.ts +666 -0
- package/src/adapters/react.tsx +603 -0
- package/src/adapters/svelte.ts +444 -0
- package/src/adapters/vanilla.ts +810 -0
- package/src/adapters/vue.ts +462 -0
- package/src/adapters/web-component.ts +858 -0
- package/src/core/feedback.ts +44 -0
- package/src/core/filter.ts +48 -0
- package/src/core/index.ts +16 -0
- package/src/core/machine.ts +373 -0
- package/src/core/timer.ts +75 -0
- package/src/core/types.ts +167 -0
- 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'
|