@radix-ui/react-one-time-password-field 0.1.0-rc.1744661316162
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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.d.mts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +612 -0
- package/dist/index.js.map +7 -0
- package/dist/index.mjs +580 -0
- package/dist/index.mjs.map +7 -0
- package/package.json +80 -0
- package/src/index.ts +16 -0
- package/src/one-time-password-field.test.tsx +50 -0
- package/src/one-time-password-field.tsx +823 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { Primitive } from '@radix-ui/react-primitive';
|
|
2
|
+
import { useComposedRefs } from '@radix-ui/react-compose-refs';
|
|
3
|
+
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
|
4
|
+
import { composeEventHandlers } from '@radix-ui/primitive';
|
|
5
|
+
import { unstable_createCollection as createCollection } from '@radix-ui/react-collection';
|
|
6
|
+
import * as RovingFocusGroup from '@radix-ui/react-roving-focus';
|
|
7
|
+
import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus';
|
|
8
|
+
import { useIsHydrated } from '@radix-ui/react-use-is-hydrated';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { flushSync } from 'react-dom';
|
|
11
|
+
import type { Scope } from '@radix-ui/react-context';
|
|
12
|
+
import { createContextScope } from '@radix-ui/react-context';
|
|
13
|
+
import { useDirection } from '@radix-ui/react-direction';
|
|
14
|
+
import { clamp } from '@radix-ui/number';
|
|
15
|
+
import { useEffectEvent } from '@radix-ui/react-use-effect-event';
|
|
16
|
+
|
|
17
|
+
type InputType = 'password' | 'text';
|
|
18
|
+
type AutoComplete = 'off' | 'one-time-code';
|
|
19
|
+
|
|
20
|
+
type KeyboardActionDetails =
|
|
21
|
+
| {
|
|
22
|
+
type: 'keydown';
|
|
23
|
+
key: 'Backspace' | 'Delete' | 'Char';
|
|
24
|
+
metaKey: boolean;
|
|
25
|
+
ctrlKey: boolean;
|
|
26
|
+
}
|
|
27
|
+
| { type: 'cut' };
|
|
28
|
+
|
|
29
|
+
type UpdateAction =
|
|
30
|
+
| {
|
|
31
|
+
type: 'SET_CHAR';
|
|
32
|
+
char: string;
|
|
33
|
+
index: number;
|
|
34
|
+
event: React.KeyboardEvent | React.ChangeEvent;
|
|
35
|
+
}
|
|
36
|
+
| { type: 'CLEAR_CHAR'; index: number; reason: 'Backspace' | 'Delete' | 'Cut' }
|
|
37
|
+
| { type: 'CLEAR'; reason: 'Reset' | 'Backspace' | 'Delete' }
|
|
38
|
+
| { type: 'PASTE'; value: string };
|
|
39
|
+
type Dispatcher = React.Dispatch<UpdateAction>;
|
|
40
|
+
|
|
41
|
+
type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none';
|
|
42
|
+
type InputValidation = Record<
|
|
43
|
+
Exclude<InputValidationType, 'none'>,
|
|
44
|
+
{ type: InputValidationType; regexp: RegExp; pattern: string; inputMode: string }
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
const INPUT_VALIDATION_MAP = {
|
|
48
|
+
numeric: {
|
|
49
|
+
type: 'numeric',
|
|
50
|
+
regexp: /[^\d]/g,
|
|
51
|
+
pattern: '\\d{1}',
|
|
52
|
+
inputMode: 'numeric',
|
|
53
|
+
},
|
|
54
|
+
alpha: {
|
|
55
|
+
type: 'alpha',
|
|
56
|
+
regexp: /[^a-zA-Z]/g,
|
|
57
|
+
pattern: '[a-zA-Z]{1}',
|
|
58
|
+
inputMode: 'text',
|
|
59
|
+
},
|
|
60
|
+
alphanumeric: {
|
|
61
|
+
type: 'alphanumeric',
|
|
62
|
+
regexp: /[^a-zA-Z0-9]/g,
|
|
63
|
+
pattern: '[a-zA-Z0-9]{1}',
|
|
64
|
+
inputMode: 'text',
|
|
65
|
+
},
|
|
66
|
+
} satisfies InputValidation;
|
|
67
|
+
|
|
68
|
+
interface OneTimePasswordFieldContextValue {
|
|
69
|
+
value: string[];
|
|
70
|
+
attemptSubmit: () => void;
|
|
71
|
+
hiddenInputRef: React.RefObject<HTMLInputElement | null>;
|
|
72
|
+
validationType: InputValidationType;
|
|
73
|
+
disabled: boolean;
|
|
74
|
+
readOnly: boolean;
|
|
75
|
+
autoComplete: AutoComplete;
|
|
76
|
+
autoFocus: boolean;
|
|
77
|
+
form: string | undefined;
|
|
78
|
+
name: string | undefined;
|
|
79
|
+
placeholder: string | undefined;
|
|
80
|
+
required: boolean;
|
|
81
|
+
type: InputType;
|
|
82
|
+
userActionRef: React.RefObject<KeyboardActionDetails | null>;
|
|
83
|
+
dispatch: Dispatcher;
|
|
84
|
+
orientation: Exclude<RovingFocusGroupProps['orientation'], undefined>;
|
|
85
|
+
preHydrationIndexTracker: React.RefObject<number>;
|
|
86
|
+
isHydrated: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ONE_TIME_PASSWORD_FIELD_NAME = 'OneTimePasswordField';
|
|
90
|
+
const [Collection, useCollection, createCollectionScope] = createCollection<HTMLInputElement>(
|
|
91
|
+
ONE_TIME_PASSWORD_FIELD_NAME
|
|
92
|
+
);
|
|
93
|
+
const [createOneTimePasswordFieldContext] = createContextScope(ONE_TIME_PASSWORD_FIELD_NAME, [
|
|
94
|
+
createCollectionScope,
|
|
95
|
+
createRovingFocusGroupScope,
|
|
96
|
+
]);
|
|
97
|
+
const useRovingFocusGroupScope = createRovingFocusGroupScope();
|
|
98
|
+
|
|
99
|
+
const [OneTimePasswordFieldContext, useOneTimePasswordFieldContext] =
|
|
100
|
+
createOneTimePasswordFieldContext<OneTimePasswordFieldContextValue>(ONE_TIME_PASSWORD_FIELD_NAME);
|
|
101
|
+
|
|
102
|
+
type RovingFocusGroupProps = React.ComponentPropsWithoutRef<typeof RovingFocusGroup.Root>;
|
|
103
|
+
|
|
104
|
+
interface OneTimePasswordFieldOwnProps {
|
|
105
|
+
onValueChange?: (value: string) => void;
|
|
106
|
+
id?: string;
|
|
107
|
+
value?: string;
|
|
108
|
+
defaultValue?: string;
|
|
109
|
+
autoSubmit?: boolean;
|
|
110
|
+
onAutoSubmit?: (value: string) => void;
|
|
111
|
+
validationType?: InputValidationType;
|
|
112
|
+
disabled?: boolean;
|
|
113
|
+
readOnly?: boolean;
|
|
114
|
+
autoComplete?: AutoComplete;
|
|
115
|
+
autoFocus?: boolean;
|
|
116
|
+
form?: string | undefined;
|
|
117
|
+
name?: string | undefined;
|
|
118
|
+
placeholder?: string | undefined;
|
|
119
|
+
required?: boolean;
|
|
120
|
+
type?: InputType;
|
|
121
|
+
//
|
|
122
|
+
dir?: RovingFocusGroupProps['dir'];
|
|
123
|
+
orientation?: RovingFocusGroupProps['orientation'];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type ScopedProps<P> = P & { __scopeOneTimePasswordField?: Scope };
|
|
127
|
+
|
|
128
|
+
const OneTimePasswordFieldCollectionProvider = ({
|
|
129
|
+
__scopeOneTimePasswordField,
|
|
130
|
+
children,
|
|
131
|
+
}: ScopedProps<{ children: React.ReactNode }>) => {
|
|
132
|
+
return (
|
|
133
|
+
<Collection.Provider scope={__scopeOneTimePasswordField}>
|
|
134
|
+
<Collection.Slot scope={__scopeOneTimePasswordField}>{children}</Collection.Slot>
|
|
135
|
+
</Collection.Provider>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
interface OneTimePasswordFieldProps
|
|
140
|
+
extends OneTimePasswordFieldOwnProps,
|
|
141
|
+
Omit<
|
|
142
|
+
React.ComponentPropsWithoutRef<typeof Primitive.div>,
|
|
143
|
+
keyof OneTimePasswordFieldOwnProps
|
|
144
|
+
> {}
|
|
145
|
+
|
|
146
|
+
const OneTimePasswordFieldImpl = React.forwardRef<HTMLDivElement, OneTimePasswordFieldProps>(
|
|
147
|
+
function OneTimePasswordFieldImpl(
|
|
148
|
+
{
|
|
149
|
+
__scopeOneTimePasswordField,
|
|
150
|
+
id,
|
|
151
|
+
defaultValue,
|
|
152
|
+
value: valueProp,
|
|
153
|
+
onValueChange,
|
|
154
|
+
autoSubmit,
|
|
155
|
+
children,
|
|
156
|
+
onPaste,
|
|
157
|
+
onAutoSubmit,
|
|
158
|
+
disabled = false,
|
|
159
|
+
readOnly = false,
|
|
160
|
+
autoComplete = 'one-time-code',
|
|
161
|
+
autoFocus = false,
|
|
162
|
+
form,
|
|
163
|
+
name,
|
|
164
|
+
placeholder,
|
|
165
|
+
required = false,
|
|
166
|
+
type = 'password',
|
|
167
|
+
// TODO: Change default to vertical when inputs use vertical writing mode
|
|
168
|
+
orientation = 'horizontal',
|
|
169
|
+
dir,
|
|
170
|
+
validationType = 'numeric',
|
|
171
|
+
...domProps
|
|
172
|
+
}: ScopedProps<OneTimePasswordFieldProps>,
|
|
173
|
+
forwardedRef
|
|
174
|
+
) {
|
|
175
|
+
const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeOneTimePasswordField);
|
|
176
|
+
const direction = useDirection(dir);
|
|
177
|
+
const collection = useCollection(__scopeOneTimePasswordField);
|
|
178
|
+
|
|
179
|
+
const validation =
|
|
180
|
+
validationType in INPUT_VALIDATION_MAP
|
|
181
|
+
? INPUT_VALIDATION_MAP[validationType as keyof InputValidation]
|
|
182
|
+
: undefined;
|
|
183
|
+
|
|
184
|
+
const controlledValue = React.useMemo(() => {
|
|
185
|
+
return valueProp != null ? sanitizeValue(valueProp, validation?.regexp) : undefined;
|
|
186
|
+
}, [valueProp, validation?.regexp]);
|
|
187
|
+
|
|
188
|
+
const [value, setValue] = useControllableState({
|
|
189
|
+
caller: 'OneTimePasswordField',
|
|
190
|
+
prop: controlledValue,
|
|
191
|
+
defaultProp: defaultValue != null ? sanitizeValue(defaultValue, validation?.regexp) : [],
|
|
192
|
+
onChange: (value) => onValueChange?.(value.filter(Boolean).join('')),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Update function *specifically* for event handlers.
|
|
196
|
+
const dispatch = useEffectEvent<Dispatcher>((action) => {
|
|
197
|
+
switch (action.type) {
|
|
198
|
+
case 'SET_CHAR': {
|
|
199
|
+
const { index, char } = action;
|
|
200
|
+
const currentTarget = collection.at(index)?.element;
|
|
201
|
+
if (value[index] === char) {
|
|
202
|
+
const next = currentTarget && collection.from(currentTarget, 1)?.element;
|
|
203
|
+
focusInput(next);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// empty values should be handled in the CLEAR_CHAR action
|
|
208
|
+
if (char === '') {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (validation) {
|
|
213
|
+
const regexp = new RegExp(validation.regexp);
|
|
214
|
+
const clean = char.replace(regexp, '');
|
|
215
|
+
if (clean !== char) {
|
|
216
|
+
// not valid; ignore
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// no more space
|
|
222
|
+
if (value.length >= collection.size) {
|
|
223
|
+
// replace current value; move to next input
|
|
224
|
+
const newValue = [...value];
|
|
225
|
+
newValue[index] = char;
|
|
226
|
+
flushSync(() => setValue(newValue));
|
|
227
|
+
const next = currentTarget && collection.from(currentTarget, 1)?.element;
|
|
228
|
+
focusInput(next);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const newValue = [
|
|
233
|
+
//
|
|
234
|
+
...value.slice(0, index),
|
|
235
|
+
char,
|
|
236
|
+
...value.slice(index),
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const lastElement = collection.at(-1)?.element;
|
|
240
|
+
flushSync(() => setValue(newValue));
|
|
241
|
+
if (currentTarget !== lastElement) {
|
|
242
|
+
const next = currentTarget && collection.from(currentTarget, 1)?.element;
|
|
243
|
+
focusInput(next);
|
|
244
|
+
} else {
|
|
245
|
+
currentTarget?.select();
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'CLEAR_CHAR': {
|
|
251
|
+
const { index, reason } = action;
|
|
252
|
+
if (!value[index]) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const newValue = value.filter((_, i) => i !== index);
|
|
257
|
+
const currentTarget = collection.at(index)?.element;
|
|
258
|
+
const previous = currentTarget && collection.from(currentTarget, -1)?.element;
|
|
259
|
+
|
|
260
|
+
flushSync(() => setValue(newValue));
|
|
261
|
+
if (reason === 'Backspace') {
|
|
262
|
+
focusInput(previous);
|
|
263
|
+
} else if (reason === 'Delete' || reason === 'Cut') {
|
|
264
|
+
focusInput(currentTarget);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case 'CLEAR': {
|
|
270
|
+
if (value.length === 0) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (action.reason === 'Backspace' || action.reason === 'Delete') {
|
|
275
|
+
flushSync(() => setValue([]));
|
|
276
|
+
focusInput(collection.at(0)?.element);
|
|
277
|
+
} else {
|
|
278
|
+
setValue([]);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case 'PASTE': {
|
|
284
|
+
const { value: pastedValue } = action;
|
|
285
|
+
const value = sanitizeValue(pastedValue, validation?.regexp);
|
|
286
|
+
if (!value) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
flushSync(() => setValue(value));
|
|
291
|
+
focusInput(collection.at(value.length - 1)?.element);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// re-validate when the validation type changes
|
|
298
|
+
const validationTypeRef = React.useRef(validation);
|
|
299
|
+
React.useEffect(() => {
|
|
300
|
+
if (!validation) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (validationTypeRef.current?.type !== validation.type) {
|
|
305
|
+
validationTypeRef.current = validation;
|
|
306
|
+
setValue(sanitizeValue(value, validation.regexp));
|
|
307
|
+
}
|
|
308
|
+
}, [setValue, validation, value]);
|
|
309
|
+
|
|
310
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null);
|
|
311
|
+
|
|
312
|
+
const userActionRef = React.useRef<KeyboardActionDetails | null>(null);
|
|
313
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
314
|
+
const composedRefs = useComposedRefs(forwardedRef, rootRef);
|
|
315
|
+
|
|
316
|
+
const firstInput = collection.at(0)?.element;
|
|
317
|
+
const locateForm = React.useCallback(() => {
|
|
318
|
+
let formElement: HTMLFormElement | null | undefined;
|
|
319
|
+
if (form) {
|
|
320
|
+
const associatedElement = (rootRef.current?.ownerDocument ?? document).getElementById(form);
|
|
321
|
+
if (isFormElement(associatedElement)) {
|
|
322
|
+
formElement = associatedElement;
|
|
323
|
+
}
|
|
324
|
+
} else if (hiddenInputRef.current) {
|
|
325
|
+
formElement = hiddenInputRef.current.form;
|
|
326
|
+
} else if (firstInput) {
|
|
327
|
+
formElement = firstInput.form;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return formElement ?? null;
|
|
331
|
+
}, [form, firstInput]);
|
|
332
|
+
|
|
333
|
+
const attemptSubmit = React.useCallback(() => {
|
|
334
|
+
const formElement = locateForm();
|
|
335
|
+
formElement?.requestSubmit();
|
|
336
|
+
}, [locateForm]);
|
|
337
|
+
|
|
338
|
+
React.useEffect(() => {
|
|
339
|
+
const form = locateForm();
|
|
340
|
+
if (form) {
|
|
341
|
+
const reset = () => dispatch({ type: 'CLEAR', reason: 'Reset' });
|
|
342
|
+
form.addEventListener('reset', reset);
|
|
343
|
+
return () => form.removeEventListener('reset', reset);
|
|
344
|
+
}
|
|
345
|
+
}, [dispatch, locateForm]);
|
|
346
|
+
|
|
347
|
+
const currentValue = value.join('');
|
|
348
|
+
const valueRef = React.useRef(currentValue);
|
|
349
|
+
const length = collection.size;
|
|
350
|
+
React.useEffect(() => {
|
|
351
|
+
const previousValue = valueRef.current;
|
|
352
|
+
valueRef.current = currentValue;
|
|
353
|
+
if (previousValue === currentValue) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (autoSubmit && value.every((char) => char !== '') && value.length === length) {
|
|
358
|
+
onAutoSubmit?.(value.join(''));
|
|
359
|
+
attemptSubmit();
|
|
360
|
+
}
|
|
361
|
+
}, [attemptSubmit, autoSubmit, currentValue, length, onAutoSubmit, value]);
|
|
362
|
+
|
|
363
|
+
// Before hydration (and in SSR) we can track the index of an input during
|
|
364
|
+
// render, as indices calculated by the collection package should almost
|
|
365
|
+
// always align with render order anyway. This ensures that index-dependent
|
|
366
|
+
// attributes are immediately rendered, in case browser extensions rely on
|
|
367
|
+
// those for auto-complete functionality and JS has not hydrated.
|
|
368
|
+
const preHydrationIndexTracker = React.useRef<number>(0);
|
|
369
|
+
const isHydrated = useIsHydrated();
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<OneTimePasswordFieldContext
|
|
373
|
+
scope={__scopeOneTimePasswordField}
|
|
374
|
+
value={value}
|
|
375
|
+
attemptSubmit={attemptSubmit}
|
|
376
|
+
disabled={disabled}
|
|
377
|
+
readOnly={readOnly}
|
|
378
|
+
autoComplete={autoComplete}
|
|
379
|
+
autoFocus={autoFocus}
|
|
380
|
+
form={form}
|
|
381
|
+
name={name}
|
|
382
|
+
placeholder={placeholder}
|
|
383
|
+
required={required}
|
|
384
|
+
type={type}
|
|
385
|
+
hiddenInputRef={hiddenInputRef}
|
|
386
|
+
userActionRef={userActionRef}
|
|
387
|
+
dispatch={dispatch}
|
|
388
|
+
validationType={validationType}
|
|
389
|
+
orientation={orientation}
|
|
390
|
+
preHydrationIndexTracker={preHydrationIndexTracker}
|
|
391
|
+
isHydrated={isHydrated}
|
|
392
|
+
>
|
|
393
|
+
<RovingFocusGroup.Root
|
|
394
|
+
asChild
|
|
395
|
+
{...rovingFocusGroupScope}
|
|
396
|
+
orientation={orientation}
|
|
397
|
+
dir={direction}
|
|
398
|
+
>
|
|
399
|
+
<Primitive.div
|
|
400
|
+
{...domProps}
|
|
401
|
+
role="group"
|
|
402
|
+
ref={composedRefs}
|
|
403
|
+
onPaste={composeEventHandlers(
|
|
404
|
+
onPaste,
|
|
405
|
+
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
const pastedValue = event.clipboardData.getData('Text');
|
|
408
|
+
const value = sanitizeValue(pastedValue, validation?.regexp);
|
|
409
|
+
if (!value) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
dispatch({ type: 'PASTE', value: pastedValue });
|
|
413
|
+
}
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
{children}
|
|
417
|
+
</Primitive.div>
|
|
418
|
+
</RovingFocusGroup.Root>
|
|
419
|
+
</OneTimePasswordFieldContext>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFieldProps>(
|
|
425
|
+
function OneTimePasswordField(props, ref) {
|
|
426
|
+
return (
|
|
427
|
+
<OneTimePasswordFieldCollectionProvider>
|
|
428
|
+
<OneTimePasswordFieldImpl {...props} ref={ref} />
|
|
429
|
+
</OneTimePasswordFieldCollectionProvider>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
interface OneTimePasswordFieldHiddenInputProps
|
|
435
|
+
extends Omit<
|
|
436
|
+
React.ComponentProps<'input'>,
|
|
437
|
+
| keyof 'value'
|
|
438
|
+
| 'defaultValue'
|
|
439
|
+
| 'type'
|
|
440
|
+
| 'onChange'
|
|
441
|
+
| 'readOnly'
|
|
442
|
+
| 'disabled'
|
|
443
|
+
| 'autoComplete'
|
|
444
|
+
| 'autoFocus'
|
|
445
|
+
> {}
|
|
446
|
+
|
|
447
|
+
const OneTimePasswordFieldHiddenInput = React.forwardRef<
|
|
448
|
+
HTMLInputElement,
|
|
449
|
+
OneTimePasswordFieldHiddenInputProps
|
|
450
|
+
>(function OneTimePasswordFieldHiddenInput(
|
|
451
|
+
{ __scopeOneTimePasswordField, ...props }: ScopedProps<OneTimePasswordFieldHiddenInputProps>,
|
|
452
|
+
forwardedRef
|
|
453
|
+
) {
|
|
454
|
+
const { value, hiddenInputRef } = useOneTimePasswordFieldContext(
|
|
455
|
+
'OneTimePasswordFieldHiddenInput',
|
|
456
|
+
__scopeOneTimePasswordField
|
|
457
|
+
);
|
|
458
|
+
const ref = useComposedRefs(hiddenInputRef, forwardedRef);
|
|
459
|
+
return (
|
|
460
|
+
<input
|
|
461
|
+
ref={ref}
|
|
462
|
+
{...props}
|
|
463
|
+
type="hidden"
|
|
464
|
+
readOnly
|
|
465
|
+
value={value.join('').trim()}
|
|
466
|
+
autoComplete="off"
|
|
467
|
+
autoFocus={false}
|
|
468
|
+
autoCapitalize="off"
|
|
469
|
+
autoCorrect="off"
|
|
470
|
+
autoSave="off"
|
|
471
|
+
spellCheck={false}
|
|
472
|
+
/>
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
interface OneTimePasswordFieldInputOwnProps {
|
|
477
|
+
autoComplete?: 'one-time-code' | 'off';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
interface OneTimePasswordFieldInputProps
|
|
481
|
+
extends OneTimePasswordFieldInputOwnProps,
|
|
482
|
+
Omit<React.ComponentProps<typeof Primitive.input>, keyof OneTimePasswordFieldInputOwnProps> {}
|
|
483
|
+
|
|
484
|
+
const OneTimePasswordFieldInput = React.forwardRef<
|
|
485
|
+
HTMLInputElement,
|
|
486
|
+
OneTimePasswordFieldInputProps
|
|
487
|
+
>(function OneTimePasswordFieldInput(
|
|
488
|
+
{ __scopeOneTimePasswordField, ...props }: ScopedProps<OneTimePasswordFieldInputProps>,
|
|
489
|
+
forwardedRef
|
|
490
|
+
) {
|
|
491
|
+
// TODO: warn if these values are passed
|
|
492
|
+
const {
|
|
493
|
+
value: _value,
|
|
494
|
+
defaultValue: _defaultValue,
|
|
495
|
+
disabled: _disabled,
|
|
496
|
+
readOnly: _readOnly,
|
|
497
|
+
autoComplete: _autoComplete,
|
|
498
|
+
autoFocus: _autoFocus,
|
|
499
|
+
form: _form,
|
|
500
|
+
name: _name,
|
|
501
|
+
placeholder: _placeholder,
|
|
502
|
+
required: _required,
|
|
503
|
+
type: _type,
|
|
504
|
+
...domProps
|
|
505
|
+
} = props as any;
|
|
506
|
+
|
|
507
|
+
const context = useOneTimePasswordFieldContext(
|
|
508
|
+
'OneTimePasswordFieldInput',
|
|
509
|
+
__scopeOneTimePasswordField
|
|
510
|
+
);
|
|
511
|
+
const { dispatch, userActionRef, validationType, preHydrationIndexTracker, isHydrated } = context;
|
|
512
|
+
const collection = useCollection(__scopeOneTimePasswordField);
|
|
513
|
+
const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeOneTimePasswordField);
|
|
514
|
+
|
|
515
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
516
|
+
const [element, setElement] = React.useState<HTMLInputElement | null>(null);
|
|
517
|
+
|
|
518
|
+
let index: number;
|
|
519
|
+
if (!isHydrated) {
|
|
520
|
+
index = preHydrationIndexTracker.current;
|
|
521
|
+
preHydrationIndexTracker.current++;
|
|
522
|
+
} else {
|
|
523
|
+
index = element ? collection.indexOf(element) : -1;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const composedInputRef = useComposedRefs(forwardedRef, inputRef, setElement);
|
|
527
|
+
const char = context.value[index] ?? '';
|
|
528
|
+
|
|
529
|
+
const keyboardActionTimeoutRef = React.useRef<number | null>(null);
|
|
530
|
+
React.useEffect(() => {
|
|
531
|
+
return () => {
|
|
532
|
+
window.clearTimeout(keyboardActionTimeoutRef.current!);
|
|
533
|
+
};
|
|
534
|
+
}, []);
|
|
535
|
+
|
|
536
|
+
const totalValue = context.value.join('').trim();
|
|
537
|
+
const lastSelectableIndex = clamp(totalValue.length, [0, collection.size - 1]);
|
|
538
|
+
const isFocusable = index <= lastSelectableIndex;
|
|
539
|
+
|
|
540
|
+
const validation =
|
|
541
|
+
validationType in INPUT_VALIDATION_MAP
|
|
542
|
+
? INPUT_VALIDATION_MAP[validationType as keyof InputValidation]
|
|
543
|
+
: undefined;
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<Collection.ItemSlot scope={__scopeOneTimePasswordField}>
|
|
547
|
+
<RovingFocusGroup.Item
|
|
548
|
+
{...rovingFocusGroupScope}
|
|
549
|
+
asChild
|
|
550
|
+
focusable={!context.disabled && isFocusable}
|
|
551
|
+
active={index === lastSelectableIndex}
|
|
552
|
+
>
|
|
553
|
+
<Primitive.input
|
|
554
|
+
ref={composedInputRef}
|
|
555
|
+
type="text"
|
|
556
|
+
aria-label={`Character ${index + 1} of ${collection.size}`}
|
|
557
|
+
autoComplete={index === 0 ? context.autoComplete : 'off'}
|
|
558
|
+
inputMode={validation?.inputMode}
|
|
559
|
+
maxLength={1}
|
|
560
|
+
pattern={validation?.pattern}
|
|
561
|
+
readOnly={context.readOnly}
|
|
562
|
+
value={char}
|
|
563
|
+
data-radix-otp-input=""
|
|
564
|
+
data-radix-index={index}
|
|
565
|
+
{...domProps}
|
|
566
|
+
onFocus={composeEventHandlers(props.onFocus, (event) => {
|
|
567
|
+
event.currentTarget.select();
|
|
568
|
+
})}
|
|
569
|
+
onCut={composeEventHandlers(props.onCut, (event) => {
|
|
570
|
+
const currentValue = event.currentTarget.value;
|
|
571
|
+
if (currentValue !== '') {
|
|
572
|
+
// In this case the value will be cleared, but we don't want to
|
|
573
|
+
// set it directly because the user may want to prevent default
|
|
574
|
+
// behavior in the onChange handler. The userActionRef will
|
|
575
|
+
// is set temporarily so the change handler can behave correctly
|
|
576
|
+
// in response to the action.
|
|
577
|
+
userActionRef.current = {
|
|
578
|
+
type: 'cut',
|
|
579
|
+
};
|
|
580
|
+
// Set a short timeout to clear the action tracker after the change
|
|
581
|
+
// handler has had time to complete.
|
|
582
|
+
keyboardActionTimeoutRef.current = window.setTimeout(() => {
|
|
583
|
+
userActionRef.current = null;
|
|
584
|
+
}, 10);
|
|
585
|
+
}
|
|
586
|
+
})}
|
|
587
|
+
onChange={composeEventHandlers(props.onChange, (event) => {
|
|
588
|
+
const action = userActionRef.current;
|
|
589
|
+
userActionRef.current = null;
|
|
590
|
+
|
|
591
|
+
if (action) {
|
|
592
|
+
switch (action.type) {
|
|
593
|
+
case 'cut':
|
|
594
|
+
// TODO: do we want to assume the user wantt to clear the
|
|
595
|
+
// entire value here and copy the code to the clipboard instead
|
|
596
|
+
// of just the value of the given input?
|
|
597
|
+
dispatch({ type: 'CLEAR_CHAR', index, reason: 'Cut' });
|
|
598
|
+
return;
|
|
599
|
+
case 'keydown': {
|
|
600
|
+
if (action.key === 'Char') {
|
|
601
|
+
// update resulting from a keydown event that set a value
|
|
602
|
+
// directly. Ignore.
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const isClearing =
|
|
607
|
+
action.key === 'Backspace' && (action.metaKey || action.ctrlKey);
|
|
608
|
+
if (isClearing) {
|
|
609
|
+
dispatch({ type: 'CLEAR', reason: 'Backspace' });
|
|
610
|
+
} else {
|
|
611
|
+
dispatch({ type: 'CLEAR_CHAR', index, reason: action.key });
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
default:
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Only update the value if it matches the input pattern
|
|
621
|
+
if (event.target.validity.valid) {
|
|
622
|
+
if (event.target.value === '') {
|
|
623
|
+
let reason: 'Backspace' | 'Delete' | 'Cut' = 'Backspace';
|
|
624
|
+
if (isInputEvent(event.nativeEvent)) {
|
|
625
|
+
const inputType = event.nativeEvent.inputType;
|
|
626
|
+
if (inputType === 'deleteContentBackward') {
|
|
627
|
+
reason = 'Backspace';
|
|
628
|
+
} else if (inputType === 'deleteByCut') {
|
|
629
|
+
reason = 'Cut';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
dispatch({ type: 'CLEAR_CHAR', index, reason });
|
|
633
|
+
} else {
|
|
634
|
+
dispatch({ type: 'SET_CHAR', char: event.target.value, index, event });
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
const element = event.target;
|
|
638
|
+
requestAnimationFrame(() => {
|
|
639
|
+
if (element.ownerDocument.activeElement === element) {
|
|
640
|
+
element.select();
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
})}
|
|
645
|
+
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
|
646
|
+
switch (event.key) {
|
|
647
|
+
case 'Delete':
|
|
648
|
+
case 'Backspace': {
|
|
649
|
+
const currentValue = event.currentTarget.value;
|
|
650
|
+
// if current value is empty, no change event will fire
|
|
651
|
+
if (currentValue === '') {
|
|
652
|
+
// if the user presses delete when there is no value, noop
|
|
653
|
+
if (event.key === 'Delete') return;
|
|
654
|
+
|
|
655
|
+
const isClearing = event.metaKey || event.ctrlKey;
|
|
656
|
+
if (isClearing) {
|
|
657
|
+
dispatch({ type: 'CLEAR', reason: 'Backspace' });
|
|
658
|
+
} else {
|
|
659
|
+
const element = event.currentTarget;
|
|
660
|
+
requestAnimationFrame(() => {
|
|
661
|
+
focusInput(collection.from(element, -1)?.element);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
// In this case the value will be cleared, but we don't want
|
|
666
|
+
// to set it directly because the user may want to prevent
|
|
667
|
+
// default behavior in the onChange handler. The userActionRef
|
|
668
|
+
// will is set temporarily so the change handler can behave
|
|
669
|
+
// correctly in response to the key vs. clearing the value by
|
|
670
|
+
// setting state externally.
|
|
671
|
+
userActionRef.current = {
|
|
672
|
+
type: 'keydown',
|
|
673
|
+
key: event.key,
|
|
674
|
+
metaKey: event.metaKey,
|
|
675
|
+
ctrlKey: event.ctrlKey,
|
|
676
|
+
};
|
|
677
|
+
// Set a short timeout to clear the action tracker after the change
|
|
678
|
+
// handler has had time to complete.
|
|
679
|
+
keyboardActionTimeoutRef.current = window.setTimeout(() => {
|
|
680
|
+
userActionRef.current = null;
|
|
681
|
+
}, 10);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
case 'Enter': {
|
|
687
|
+
event.preventDefault();
|
|
688
|
+
context.attemptSubmit();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
case 'ArrowDown':
|
|
692
|
+
case 'ArrowUp': {
|
|
693
|
+
if (context.orientation === 'horizontal') {
|
|
694
|
+
// in horizontal orientation, the up/down will de-select the
|
|
695
|
+
// input instead of moving focus
|
|
696
|
+
event.preventDefault();
|
|
697
|
+
}
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
// TODO: Handle left/right arrow keys in vertical writing mode
|
|
701
|
+
default: {
|
|
702
|
+
if (event.currentTarget.value === event.key) {
|
|
703
|
+
// if current value is same as the key press, no change event
|
|
704
|
+
// will fire. Focus the next input.
|
|
705
|
+
const element = event.currentTarget;
|
|
706
|
+
event.preventDefault();
|
|
707
|
+
focusInput(collection.from(element, 1)?.element);
|
|
708
|
+
return;
|
|
709
|
+
} else if (
|
|
710
|
+
// input already has a value, but...
|
|
711
|
+
event.currentTarget.value &&
|
|
712
|
+
// the value is not selected
|
|
713
|
+
!(
|
|
714
|
+
event.currentTarget.selectionStart === 0 &&
|
|
715
|
+
event.currentTarget.selectionEnd != null &&
|
|
716
|
+
event.currentTarget.selectionEnd > 0
|
|
717
|
+
)
|
|
718
|
+
) {
|
|
719
|
+
const attemptedValue = event.key;
|
|
720
|
+
if (event.key.length > 1 || event.key === ' ') {
|
|
721
|
+
// not a character; do nothing
|
|
722
|
+
return;
|
|
723
|
+
} else {
|
|
724
|
+
// user is attempting to enter a character, but the input
|
|
725
|
+
// will not update by default since it's limited to a single
|
|
726
|
+
// character.
|
|
727
|
+
const nextInput = collection.from(event.currentTarget, 1)?.element;
|
|
728
|
+
const lastInput = collection.at(-1)?.element;
|
|
729
|
+
if (nextInput !== lastInput && event.currentTarget !== lastInput) {
|
|
730
|
+
// if selection is before the value, set the value of the
|
|
731
|
+
// current input. Otherwise set the value of the next
|
|
732
|
+
// input.
|
|
733
|
+
if (event.currentTarget.selectionStart === 0) {
|
|
734
|
+
dispatch({ type: 'SET_CHAR', char: attemptedValue, index, event });
|
|
735
|
+
} else {
|
|
736
|
+
dispatch({
|
|
737
|
+
type: 'SET_CHAR',
|
|
738
|
+
char: attemptedValue,
|
|
739
|
+
index: index + 1,
|
|
740
|
+
event,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
userActionRef.current = {
|
|
745
|
+
type: 'keydown',
|
|
746
|
+
key: 'Char',
|
|
747
|
+
metaKey: event.metaKey,
|
|
748
|
+
ctrlKey: event.ctrlKey,
|
|
749
|
+
};
|
|
750
|
+
keyboardActionTimeoutRef.current = window.setTimeout(() => {
|
|
751
|
+
userActionRef.current = null;
|
|
752
|
+
}, 10);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
})}
|
|
759
|
+
onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
|
|
760
|
+
if (index > lastSelectableIndex) {
|
|
761
|
+
event.preventDefault();
|
|
762
|
+
const element = collection.at(lastSelectableIndex)?.element;
|
|
763
|
+
focusInput(element);
|
|
764
|
+
}
|
|
765
|
+
})}
|
|
766
|
+
/>
|
|
767
|
+
</RovingFocusGroup.Item>
|
|
768
|
+
</Collection.ItemSlot>
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const Root = OneTimePasswordField;
|
|
773
|
+
const Input = OneTimePasswordFieldInput;
|
|
774
|
+
const HiddenInput = OneTimePasswordFieldHiddenInput;
|
|
775
|
+
|
|
776
|
+
export {
|
|
777
|
+
OneTimePasswordField,
|
|
778
|
+
OneTimePasswordFieldInput,
|
|
779
|
+
OneTimePasswordFieldHiddenInput,
|
|
780
|
+
//
|
|
781
|
+
Root,
|
|
782
|
+
Input,
|
|
783
|
+
HiddenInput,
|
|
784
|
+
};
|
|
785
|
+
export type {
|
|
786
|
+
OneTimePasswordFieldProps,
|
|
787
|
+
OneTimePasswordFieldInputProps,
|
|
788
|
+
OneTimePasswordFieldHiddenInputProps,
|
|
789
|
+
InputValidationType,
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
function isFormElement(element: Element | null | undefined): element is HTMLFormElement {
|
|
793
|
+
return element?.tagName === 'FORM';
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function sanitizeValue(value: string | string[], regexp: RegExp | undefined | null) {
|
|
797
|
+
if (Array.isArray(value)) {
|
|
798
|
+
value = value.join('');
|
|
799
|
+
}
|
|
800
|
+
if (regexp) {
|
|
801
|
+
// global regexp is stateful, so we clone it for each call
|
|
802
|
+
regexp = new RegExp(regexp);
|
|
803
|
+
return value.replace(regexp, '').split('').filter(Boolean);
|
|
804
|
+
}
|
|
805
|
+
return value.split('').filter(Boolean);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function focusInput(element: HTMLInputElement | null | undefined) {
|
|
809
|
+
if (!element) return;
|
|
810
|
+
if (element.ownerDocument.activeElement === element) {
|
|
811
|
+
// if the element is already focused, select the value in the next
|
|
812
|
+
// animation frame
|
|
813
|
+
window.requestAnimationFrame(() => {
|
|
814
|
+
element.select?.();
|
|
815
|
+
});
|
|
816
|
+
} else {
|
|
817
|
+
element.focus();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function isInputEvent(event: Event): event is InputEvent {
|
|
822
|
+
return event.type === 'input';
|
|
823
|
+
}
|