UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

232 lines (230 loc) 8.41 kB
'use client'; import * as React from 'react'; import { SafeReact } from '@base-ui/utils/safeReact'; import { warn } from '@base-ui/utils/warn'; import { stopEvent } from "../../floating-ui-react/utils.js"; import { IndexGuessBehavior, useCompositeListItem } from "../../internals/composite/list/useCompositeListItem.js"; import { useRenderElement } from "../../internals/useRenderElement.js"; import { createChangeEventDetails, createGenericEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { useOTPFieldRootContext, getOTPFieldInputState } from "../root/OTPFieldRootContext.js"; import { inputStateAttributesMapping } from "../utils/stateAttributesMapping.js"; import { normalizeOTPValue, removeOTPCharacter, replaceOTPValue, stripOTPWhitespace } from "../utils/otp.js"; /** * An individual OTP character input. * Renders an `<input>` element. * * Documentation: [Base UI OTP Field](https://base-ui.com/react/components/otp-field) */ export const OTPFieldInput = /*#__PURE__*/React.forwardRef(function OTPFieldInput(componentProps, forwardedRef) { const { 'aria-label': externalAriaLabel, 'aria-labelledby': externalAriaLabelledBy, render, className, style, ...elementProps } = componentProps; const { activeIndex, autoComplete, disabled, form, focusInput, queueFocusInput, getInputId, handleInputBlur, handleInputFocus, inputMode, inputAriaLabelledBy, invalid, length, mask, pattern, reportValueInvalid, readOnly, required, sanitizeValue, setValue, state, validationType, value } = useOTPFieldRootContext(); const { ref: listItemRef, index } = useCompositeListItem({ indexGuessBehavior: IndexGuessBehavior.GuessFromOrder }); const inputRef = React.useRef(null); const slotValue = value[index] ?? ''; const inputState = getOTPFieldInputState(state, slotValue, index); const slotAriaLabel = externalAriaLabel; const inheritedLabel = externalAriaLabelledBy ?? inputAriaLabelledBy; const ariaLabel = index === 0 ? undefined : slotAriaLabel; if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { if (index !== 0 || slotAriaLabel == null || inputRef.current?.labels?.length) { return; } const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; warn('<OTPField.Input> ignores `aria-label` on the first input. Use a `<label>` or `<Field.Label>` to label the OTP field.', ownerStackMessage); }, [index, slotAriaLabel]); } const inputProps = { id: getInputId(index), value: slotValue, type: mask ? 'password' : 'text', inputMode, autoComplete: index === 0 ? autoComplete : 'off', autoCorrect: 'off', spellCheck: 'false', enterKeyHint: index === length - 1 ? 'done' : 'next', // Allow the first slot to accept a full code so browser paste/autofill can target it directly. maxLength: index === 0 ? length : 1, tabIndex: activeIndex === index ? 0 : -1, disabled, form, pattern, readOnly, required, 'aria-labelledby': ariaLabel == null ? inheritedLabel : undefined, 'aria-invalid': invalid || undefined, 'aria-label': ariaLabel, onMouseDown(event) { if (event.defaultPrevented || disabled) { return; } event.preventDefault(); focusInput(index); }, onFocus(event) { if (event.defaultPrevented || disabled) { return; } handleInputFocus(index, event); }, onBlur(event) { if (event.defaultPrevented) { return; } handleInputBlur(event); }, onChange(event) { if (event.defaultPrevented || disabled || readOnly) { return; } const rawValue = event.currentTarget.value; const nextDigits = normalizeOTPValue(event.currentTarget.value, length, validationType, sanitizeValue); const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { reportValueInvalid(rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent)); } if (nextDigits === '') { if (rawValue === '') { setValue(removeOTPCharacter(value, index), createChangeEventDetails(REASONS.inputClear, event.nativeEvent)); } else if (slotValue !== '') { event.currentTarget.value = slotValue; event.currentTarget.select(); } return; } const nextValue = replaceOTPValue(value, index, nextDigits, length, validationType, sanitizeValue); const committedValue = setValue(nextValue, createChangeEventDetails(REASONS.inputChange, event.nativeEvent)); if (committedValue != null) { const nextInput = Math.min(index + nextDigits.length, length - 1); queueFocusInput(nextInput, committedValue); } }, onKeyDown(event) { if (event.defaultPrevented || disabled) { return; } if (event.key === 'ArrowLeft') { stopEvent(event); focusInput(Math.max(0, index - 1)); return; } if (event.key === 'ArrowRight') { stopEvent(event); focusInput(Math.min(length - 1, index + 1)); return; } if (event.key === 'Home') { stopEvent(event); focusInput(0); return; } if (event.key === 'End') { stopEvent(event); focusInput(Math.max(value.length - 1, 0)); return; } if (readOnly) { return; } if (event.key === 'Delete') { stopEvent(event); const committedValue = setValue(removeOTPCharacter(value, index), createChangeEventDetails(REASONS.keyboard, event.nativeEvent)); if (committedValue != null) { queueFocusInput(index, committedValue); } return; } const inputValue = event.currentTarget.value; const fullSelection = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === inputValue.length; if (event.key.length === 1 && fullSelection && slotValue === event.key) { stopEvent(event); focusInput(Math.min(length - 1, index + 1)); return; } if (event.key === 'Backspace') { stopEvent(event); const deleteIndex = slotValue === '' ? Math.max(0, index - 1) : index; const targetIndex = Math.max(0, index - 1); const committedValue = setValue(removeOTPCharacter(value, deleteIndex), createChangeEventDetails(REASONS.keyboard, event.nativeEvent)); if (committedValue != null) { queueFocusInput(targetIndex, committedValue); } } }, onPaste(event) { if (event.defaultPrevented || disabled || readOnly) { return; } let rawValue = ''; try { rawValue = event.clipboardData?.getData('text/plain') ?? ''; } catch { if (process.env.NODE_ENV !== 'production') { const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; warn('<OTPField.Input> could not read clipboard text during paste handling.', ownerStackMessage); } return; } event.preventDefault(); const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length; if (didSanitize) { reportValueInvalid(rawValue, createGenericEventDetails(REASONS.inputPaste, event.nativeEvent)); } if (nextDigits === '') { return; } const committedValue = setValue(replaceOTPValue(value, index, nextDigits, length, validationType, sanitizeValue), createChangeEventDetails(REASONS.inputPaste, event.nativeEvent)); if (committedValue != null) { const nextInput = Math.min(index + nextDigits.length, length - 1); queueFocusInput(nextInput, committedValue); } } }; const element = useRenderElement('input', componentProps, { ref: [forwardedRef, listItemRef, inputRef], state: inputState, props: [inputProps, elementProps], stateAttributesMapping: inputStateAttributesMapping }); return element; }); if (process.env.NODE_ENV !== "production") OTPFieldInput.displayName = "OTPFieldInput";