@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.
@@ -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
+ }