import * as Primitive from '@radix-ui/react-primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { composeEventHandlers } from '@radix-ui/primitive'; import { unstable_createCollection as createCollection } from '@radix-ui/react-collection'; import * as RovingFocusGroup from '@radix-ui/react-roving-focus'; import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus'; import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; import * as React from 'react'; import { flushSync } from 'react-dom'; import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; import { useDirection } from '@radix-ui/react-direction'; import { clamp } from '@radix-ui/number'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none'; const INPUT_VALIDATION_MAP = { numeric: { type: 'numeric', regexp: /[^\d]/g, pattern: '\\d{1}', inputMode: 'numeric', }, alpha: { type: 'alpha', regexp: /[^a-zA-Z]/g, pattern: '[a-zA-Z]{1}', inputMode: 'text', }, alphanumeric: { type: 'alphanumeric', regexp: /[^a-zA-Z0-9]/g, pattern: '[a-zA-Z0-9]{1}', inputMode: 'text', }, none: null, } satisfies InputValidation; /* ------------------------------------------------------------------------------------------------- * OneTimePasswordFieldProvider * -----------------------------------------------------------------------------------------------*/ type RovingFocusGroupProps = RovingFocusGroup.RovingFocusGroupProps; interface OneTimePasswordFieldContextValue { attemptSubmit: () => void; autoComplete: AutoComplete; autoFocus: boolean; disabled: boolean; dispatch: Dispatcher; form: string | undefined; hiddenInputRef: React.RefObject; isHydrated: boolean; name: string | undefined; orientation: Exclude; placeholder: string | undefined; readOnly: boolean; type: InputType; userActionRef: React.RefObject; validationType: InputValidationType; value: string[]; sanitizeValue: (arg: string | string[]) => string[]; } const ONE_TIME_PASSWORD_FIELD_NAME = 'OneTimePasswordField'; const [Collection, { useCollection, createCollectionScope, useInitCollection }] = createCollection(ONE_TIME_PASSWORD_FIELD_NAME); const [createOneTimePasswordFieldContext] = createContextScope(ONE_TIME_PASSWORD_FIELD_NAME, [ createCollectionScope, createRovingFocusGroupScope, ]); const useRovingFocusGroupScope = createRovingFocusGroupScope(); const [OneTimePasswordFieldContext, useOneTimePasswordFieldContext] = createOneTimePasswordFieldContext(ONE_TIME_PASSWORD_FIELD_NAME); /* ------------------------------------------------------------------------------------------------- * OneTimePasswordField * -----------------------------------------------------------------------------------------------*/ interface OneTimePasswordFieldOwnProps { /** * Specifies what—if any—permission the user agent has to provide automated * assistance in filling out form field values, as well as guidance to the * browser as to the type of information expected in the field. Allows * `"one-time-code"` or `"off"`. * * @defaultValue `"one-time-code"` * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete */ autoComplete?: AutoComplete; /** * Whether or not the first fillable input should be focused on page-load. * * @defaultValue `false` * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/autofocus */ autoFocus?: boolean; /** * Whether or not the component should attempt to automatically submit when * all fields are filled. If the field is associated with an HTML `form` * element, the form's `requestSubmit` method will be called. * * @defaultValue `false` */ autoSubmit?: boolean; /** * The initial value of the uncontrolled field. */ defaultValue?: string; /** * Indicates the horizontal directionality of the parent element's text. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir */ dir?: RovingFocusGroupProps['dir']; /** * Whether or not the the field's input elements are disabled. */ disabled?: boolean; /** * A string specifying the `form` element with which the input is associated. * This string's value, if present, must match the id of a `form` element in * the same document. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form */ form?: string | undefined; /** * A string specifying a name for the input control. This name is submitted * along with the control's value when the form data is submitted. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#name */ name?: string | undefined; /** * When the `autoSubmit` prop is set to `true`, this callback will be fired * before attempting to submit the associated form. It will be called whether * or not a form is located, or if submission is not allowed. */ onAutoSubmit?: (value: string) => void; /** * A callback fired when the field's value changes. When the component is * controlled, this should update the state passed to the `value` prop. */ onValueChange?: (value: string) => void; /** * Indicates the vertical directionality of the input elements. * * @defaultValue `"horizontal"` */ orientation?: RovingFocusGroupProps['orientation']; /** * Defines the text displayed in a form control when the control has no value. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/placeholder */ placeholder?: string | undefined; /** * Whether or not the input elements can be updated by the user. * * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/readonly */ readOnly?: boolean; /** * Function for custom sanitization when `validationType` is set to `"none"`. * This function will be called before updating values in response to user * interactions. */ sanitizeValue?: (value: string) => string; /** * The input type of the field's input elements. Can be `"password"` or `"text"`. */ type?: InputType; /** * Specifies the type of input validation to be used. Can be `"alpha"`, * `"numeric"`, `"alphanumeric"` or `"none"`. * * @defaultValue `"numeric"` */ validationType?: InputValidationType; /** * The controlled value of the field. */ value?: string; } type ScopedProps

= P & { __scopeOneTimePasswordField?: Scope }; interface OneTimePasswordFieldProps extends OneTimePasswordFieldOwnProps, Omit, keyof OneTimePasswordFieldOwnProps> {} const OneTimePasswordField = React.forwardRef( function OneTimePasswordFieldImpl( { __scopeOneTimePasswordField, defaultValue, value: valueProp, onValueChange, autoSubmit = false, children, onPaste, onAutoSubmit, disabled = false, readOnly = false, autoComplete = 'one-time-code', autoFocus = false, form, name, placeholder, type = 'text', // TODO: Change default to vertical when inputs use vertical writing mode orientation = 'horizontal', dir, validationType = 'numeric', sanitizeValue: sanitizeValueProp, ...domProps }: ScopedProps, forwardedRef ) { const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeOneTimePasswordField); const direction = useDirection(dir); const collectionState = useInitCollection(); const [collection] = collectionState; const validation = INPUT_VALIDATION_MAP[validationType] ? INPUT_VALIDATION_MAP[validationType as keyof InputValidation] : null; const sanitizeValue = React.useCallback( (value: string | string[]) => { if (Array.isArray(value)) { value = value.map(removeWhitespace).join(''); } else { value = removeWhitespace(value); } if (validation) { // global regexp is stateful, so we clone it for each call const regexp = new RegExp(validation.regexp); value = value.replace(regexp, ''); } else if (sanitizeValueProp) { value = sanitizeValueProp(value); } return value.split(''); }, [validation, sanitizeValueProp] ); const controlledValue = React.useMemo(() => { return valueProp != null ? sanitizeValue(valueProp) : undefined; }, [valueProp, sanitizeValue]); const [value, setValue] = useControllableState({ caller: 'OneTimePasswordField', prop: controlledValue, defaultProp: defaultValue != null ? sanitizeValue(defaultValue) : [], onChange: React.useCallback( (value: string[]) => onValueChange?.(value.join('')), [onValueChange] ), }); // Update function *specifically* for event handlers. const dispatch = useEffectEvent((action) => { switch (action.type) { case 'SET_CHAR': { const { index, char } = action; const currentTarget = collection.at(index)?.element; if (value[index] === char) { const next = currentTarget && collection.from(currentTarget, 1)?.element; focusInput(next); return; } // empty values should be handled in the CLEAR_CHAR action if (char === '') { return; } if (validation) { const regexp = new RegExp(validation.regexp); const clean = char.replace(regexp, ''); if (clean !== char) { // not valid; ignore return; } } // no more space if (value.length >= collection.size) { // replace current value; move to next input const newValue = [...value]; newValue[index] = char; flushSync(() => setValue(newValue)); const next = currentTarget && collection.from(currentTarget, 1)?.element; focusInput(next); return; } const newValue = [...value]; newValue[index] = char; const lastElement = collection.at(-1)?.element; flushSync(() => setValue(newValue)); if (currentTarget !== lastElement) { const next = currentTarget && collection.from(currentTarget, 1)?.element; focusInput(next); } else { currentTarget?.select(); } return; } case 'CLEAR_CHAR': { const { index, reason } = action; if (!value[index]) { return; } const newValue = value.filter((_, i) => i !== index); const currentTarget = collection.at(index)?.element; const previous = currentTarget && collection.from(currentTarget, -1)?.element; flushSync(() => setValue(newValue)); if (reason === 'Backspace') { focusInput(previous); } else if (reason === 'Delete' || reason === 'Cut') { focusInput(currentTarget); } return; } case 'CLEAR': { if (value.length === 0) { return; } if (action.reason === 'Backspace' || action.reason === 'Delete') { flushSync(() => setValue([])); focusInput(collection.at(0)?.element); } else { setValue([]); } return; } case 'PASTE': { const { value: pastedValue } = action; const value = sanitizeValue(pastedValue); if (!value) { return; } flushSync(() => setValue(value)); focusInput(collection.at(value.length - 1)?.element); return; } } }); // re-validate when the validation type changes const validationTypeRef = React.useRef(validation); React.useEffect(() => { if (!validation) { return; } if (validationTypeRef.current?.type !== validation.type) { validationTypeRef.current = validation; setValue(sanitizeValue(value.join(''))); } }, [sanitizeValue, setValue, validation, value]); const hiddenInputRef = React.useRef(null); const userActionRef = React.useRef(null); const rootRef = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, rootRef); const firstInput = collection.at(0)?.element; const locateForm = React.useCallback(() => { let formElement: HTMLFormElement | null | undefined; if (form) { const associatedElement = (rootRef.current?.ownerDocument ?? document).getElementById(form); if (isFormElement(associatedElement)) { formElement = associatedElement; } } else if (hiddenInputRef.current) { formElement = hiddenInputRef.current.form; } else if (firstInput) { formElement = firstInput.form; } return formElement ?? null; }, [form, firstInput]); const attemptSubmit = React.useCallback(() => { const formElement = locateForm(); formElement?.requestSubmit(); }, [locateForm]); React.useEffect(() => { const form = locateForm(); if (form) { const reset = () => dispatch({ type: 'CLEAR', reason: 'Reset' }); form.addEventListener('reset', reset); return () => form.removeEventListener('reset', reset); } }, [dispatch, locateForm]); const currentValue = value.join(''); const valueRef = React.useRef(currentValue); const length = collection.size; React.useEffect(() => { const previousValue = valueRef.current; valueRef.current = currentValue; if (previousValue === currentValue) { return; } if (autoSubmit && value.every((char) => char !== '') && value.length === length) { onAutoSubmit?.(value.join('')); attemptSubmit(); } }, [attemptSubmit, autoSubmit, currentValue, length, onAutoSubmit, value]); const isHydrated = useIsHydrated(); return ( ) => { event.preventDefault(); const pastedValue = event.clipboardData.getData('Text'); dispatch({ type: 'PASTE', value: pastedValue }); } )} > {children} ); } ); /* ------------------------------------------------------------------------------------------------- * OneTimePasswordFieldHiddenInput * -----------------------------------------------------------------------------------------------*/ interface OneTimePasswordFieldHiddenInputProps extends Omit< React.ComponentProps<'input'>, | keyof 'value' | 'defaultValue' | 'type' | 'onChange' | 'readOnly' | 'disabled' | 'autoComplete' | 'autoFocus' > {} const OneTimePasswordFieldHiddenInput = React.forwardRef< HTMLInputElement, OneTimePasswordFieldHiddenInputProps >(function OneTimePasswordFieldHiddenInput( { __scopeOneTimePasswordField, ...props }: ScopedProps, forwardedRef ) { const { value, hiddenInputRef, name } = useOneTimePasswordFieldContext( 'OneTimePasswordFieldHiddenInput', __scopeOneTimePasswordField ); const ref = useComposedRefs(hiddenInputRef, forwardedRef); return ( ); }); /* ------------------------------------------------------------------------------------------------- * OneTimePasswordFieldInput * -----------------------------------------------------------------------------------------------*/ interface OneTimePasswordFieldInputProps extends Omit< Primitive.PrimitivePropsWithRef<'input'>, | 'value' | 'defaultValue' | 'disabled' | 'readOnly' | 'autoComplete' | 'autoFocus' | 'form' | 'name' | 'placeholder' | 'type' > { /** * Callback fired when the user input fails native HTML input validation. */ onInvalidChange?: (character: string) => void; /** * User-provided index to determine the order of the inputs. This is useful if * you need certain index-based attributes to be set on the initial render, * often to prevent flickering after hydration. */ index?: number; } const OneTimePasswordFieldInput = React.forwardRef< HTMLInputElement, OneTimePasswordFieldInputProps >(function OneTimePasswordFieldInput( { __scopeOneTimePasswordField, onInvalidChange, index: indexProp, ...props }: ScopedProps, forwardedRef ) { // TODO: warn if these values are passed const { value: _value, defaultValue: _defaultValue, disabled: _disabled, readOnly: _readOnly, autoComplete: _autoComplete, autoFocus: _autoFocus, form: _form, name: _name, placeholder: _placeholder, type: _type, ...domProps } = props as Primitive.PrimitivePropsWithRef<'input'>; const context = useOneTimePasswordFieldContext( 'OneTimePasswordFieldInput', __scopeOneTimePasswordField ); const { dispatch, userActionRef, validationType, isHydrated, disabled } = context; const collection = useCollection(__scopeOneTimePasswordField); const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeOneTimePasswordField); const inputRef = React.useRef(null); const [element, setElement] = React.useState(null); const index = indexProp ?? (element ? collection.indexOf(element) : -1); const canSetPlaceholder = indexProp != null || isHydrated; let placeholder: string | undefined; if (canSetPlaceholder && context.placeholder && context.value.length === 0) { // only set placeholder after hydration to prevent flickering when indices // are re-calculated placeholder = context.placeholder[index]; } const composedInputRef = useComposedRefs(forwardedRef, inputRef, setElement); const char = context.value[index] ?? ''; const keyboardActionTimeoutRef = React.useRef(null); React.useEffect(() => { return () => { window.clearTimeout(keyboardActionTimeoutRef.current!); }; }, []); const totalValue = context.value.join('').trim(); const lastSelectableIndex = clamp(totalValue.length, [0, collection.size - 1]); const isFocusable = index <= lastSelectableIndex; const validation = validationType in INPUT_VALIDATION_MAP ? INPUT_VALIDATION_MAP[validationType as keyof InputValidation] : undefined; return ( {({ hasTabStop, isCurrentTabStop }) => { const supportsAutoComplete = hasTabStop ? isCurrentTabStop : index === 0; return ( { event.currentTarget.select(); })} onCut={composeEventHandlers(props.onCut, (event) => { const currentValue = event.currentTarget.value; if (currentValue !== '') { // In this case the value will be cleared, but we don't want to // set it directly because the user may want to prevent default // behavior in the onChange handler. The userActionRef will // is set temporarily so the change handler can behave correctly // in response to the action. userActionRef.current = { type: 'cut', }; // Set a short timeout to clear the action tracker after the change // handler has had time to complete. keyboardActionTimeoutRef.current = window.setTimeout(() => { userActionRef.current = null; }, 10); } })} onInput={composeEventHandlers(props.onInput, (event) => { const value = event.currentTarget.value; if (value.length > 1) { // Password managers may try to insert the code into a single // input, in which case form validation will fail to prevent // additional input. Handle this the same as if a user were // pasting a value. event.preventDefault(); dispatch({ type: 'PASTE', value }); } })} onChange={composeEventHandlers(props.onChange, (event) => { const value = event.target.value; event.preventDefault(); const action = userActionRef.current; userActionRef.current = null; if (action) { switch (action.type) { case 'cut': // TODO: do we want to assume the user wantt to clear the // entire value here and copy the code to the clipboard instead // of just the value of the given input? dispatch({ type: 'CLEAR_CHAR', index, reason: 'Cut' }); return; case 'keydown': { if (action.key === 'Char') { // update resulting from a keydown event that set a value // directly. Ignore. return; } const isClearing = action.key === 'Backspace' && (action.metaKey || action.ctrlKey); if (action.key === 'Clear' || isClearing) { dispatch({ type: 'CLEAR', reason: 'Backspace' }); } else { dispatch({ type: 'CLEAR_CHAR', index, reason: action.key }); } return; } default: return; } } // Only update the value if it matches the input pattern if (event.target.validity.valid) { if (value === '') { let reason: 'Backspace' | 'Delete' | 'Cut' = 'Backspace'; if (isInputEvent(event.nativeEvent)) { const inputType = event.nativeEvent.inputType; if (inputType === 'deleteContentBackward') { reason = 'Backspace'; } else if (inputType === 'deleteByCut') { reason = 'Cut'; } } dispatch({ type: 'CLEAR_CHAR', index, reason }); } else { dispatch({ type: 'SET_CHAR', char: value, index, event }); } } else { const element = event.target; onInvalidChange?.(element.value); requestAnimationFrame(() => { if (element.ownerDocument.activeElement === element) { element.select(); } }); } })} onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { switch (event.key) { case 'Clear': case 'Delete': case 'Backspace': { const currentValue = event.currentTarget.value; // if current value is empty, no change event will fire if (currentValue === '') { // if the user presses delete when there is no value, noop if (event.key === 'Delete') return; const isClearing = event.key === 'Clear' || event.metaKey || event.ctrlKey; if (isClearing) { dispatch({ type: 'CLEAR', reason: 'Backspace' }); } else { const element = event.currentTarget; requestAnimationFrame(() => { focusInput(collection.from(element, -1)?.element); }); } } else { // In this case the value will be cleared, but we don't want // to set it directly because the user may want to prevent // default behavior in the onChange handler. The userActionRef // will is set temporarily so the change handler can behave // correctly in response to the key vs. clearing the value by // setting state externally. userActionRef.current = { type: 'keydown', key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey, }; // Set a short timeout to clear the action tracker after the change // handler has had time to complete. keyboardActionTimeoutRef.current = window.setTimeout(() => { userActionRef.current = null; }, 10); } return; } case 'Enter': { event.preventDefault(); context.attemptSubmit(); return; } case 'ArrowDown': case 'ArrowUp': { if (context.orientation === 'horizontal') { // in horizontal orientation, the up/down will de-select the // input instead of moving focus event.preventDefault(); } return; } // TODO: Handle left/right arrow keys in vertical writing mode default: { if (event.currentTarget.value === event.key) { // if current value is same as the key press, no change event // will fire. Focus the next input. const element = event.currentTarget; event.preventDefault(); focusInput(collection.from(element, 1)?.element); return; } else if ( // input already has a value, but... event.currentTarget.value && // the value is not selected !( event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd != null && event.currentTarget.selectionEnd > 0 ) ) { const attemptedValue = event.key; if (event.key.length > 1 || event.key === ' ') { // not a character; do nothing return; } else { // user is attempting to enter a character, but the input // will not update by default since it's limited to a single // character. const nextInput = collection.from(event.currentTarget, 1)?.element; const lastInput = collection.at(-1)?.element; if (nextInput !== lastInput && event.currentTarget !== lastInput) { // if selection is before the value, set the value of the // current input. Otherwise set the value of the next // input. if (event.currentTarget.selectionStart === 0) { dispatch({ type: 'SET_CHAR', char: attemptedValue, index, event }); } else { dispatch({ type: 'SET_CHAR', char: attemptedValue, index: index + 1, event, }); } userActionRef.current = { type: 'keydown', key: 'Char', metaKey: event.metaKey, ctrlKey: event.ctrlKey, }; keyboardActionTimeoutRef.current = window.setTimeout(() => { userActionRef.current = null; }, 10); } } } } } })} onPointerDown={composeEventHandlers(props.onPointerDown, (event) => { event.preventDefault(); const indexToFocus = Math.min(index, lastSelectableIndex); const element = collection.at(indexToFocus)?.element; focusInput(element); })} /> ); }} ); }); export { OneTimePasswordField, OneTimePasswordFieldInput, OneTimePasswordFieldHiddenInput, // OneTimePasswordField as Root, OneTimePasswordFieldInput as Input, OneTimePasswordFieldHiddenInput as HiddenInput, }; export type { OneTimePasswordFieldProps, OneTimePasswordFieldInputProps, OneTimePasswordFieldHiddenInputProps, InputValidationType, }; /* -----------------------------------------------------------------------------------------------*/ function isFormElement(element: Element | null | undefined): element is HTMLFormElement { return element?.tagName === 'FORM'; } function removeWhitespace(value: string) { return value.replace(/\s/g, ''); } function focusInput(element: HTMLInputElement | null | undefined) { if (!element) return; if (element.ownerDocument.activeElement === element) { // if the element is already focused, select the value in the next // animation frame window.requestAnimationFrame(() => { element.select?.(); }); } else { element.focus(); } } function isInputEvent(event: Event): event is InputEvent { return event.type === 'input'; } type InputType = 'password' | 'text'; type AutoComplete = 'off' | 'one-time-code'; type KeyboardActionDetails = | { type: 'keydown'; key: 'Backspace' | 'Delete' | 'Clear' | 'Char'; metaKey: boolean; ctrlKey: boolean; } | { type: 'cut' }; type UpdateAction = | { type: 'SET_CHAR'; char: string; index: number; event: React.KeyboardEvent | React.ChangeEvent; } | { type: 'CLEAR_CHAR'; index: number; reason: 'Backspace' | 'Delete' | 'Cut' } | { type: 'CLEAR'; reason: 'Reset' | 'Backspace' | 'Delete' | 'Clear' } | { type: 'PASTE'; value: string }; type Dispatcher = React.Dispatch; type InputValidation = Record< InputValidationType, { type: InputValidationType; regexp: RegExp; pattern: string; inputMode: 'text' | 'numeric'; } | null >;