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.

364 lines (362 loc) 13.2 kB
'use client'; import * as React from 'react'; import { SafeReact } from '@base-ui/utils/safeReact'; import { useControlled } from '@base-ui/utils/useControlled'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useValueAsRef } from '@base-ui/utils/useValueAsRef'; import { visuallyHidden, visuallyHiddenInput } from '@base-ui/utils/visuallyHidden'; import { warn } from '@base-ui/utils/warn'; import { ownerDocument } from '@base-ui/utils/owner'; import { contains } from "../../floating-ui-react/utils.js"; import { CompositeList } from "../../internals/composite/list/CompositeList.js"; import { useFieldRootContext } from "../../internals/field-root-context/FieldRootContext.js"; import { useRegisterFieldControl } from "../../internals/field-register-control/useRegisterFieldControl.js"; import { useFormContext } from "../../internals/form-context/FormContext.js"; import { useLabelableContext } from "../../internals/labelable-provider/LabelableContext.js"; import { useAriaLabelledBy } from "../../internals/labelable-provider/useAriaLabelledBy.js"; import { useLabelableId } from "../../internals/labelable-provider/useLabelableId.js"; import { useRenderElement } from "../../internals/useRenderElement.js"; import { useValueChanged } from "../../internals/useValueChanged.js"; import { createChangeEventDetails, createGenericEventDetails } from "../../internals/createBaseUIEventDetails.js"; import { REASONS } from "../../internals/reasons.js"; import { OTPFieldRootContext } from "./OTPFieldRootContext.js"; import { rootStateAttributesMapping } from "../utils/stateAttributesMapping.js"; import { getOTPValidationConfig, normalizeOTPValue, stripOTPWhitespace } from "../utils/otp.js"; /** * Groups all OTP field parts and manages their state. * Renders a `<div>` element. * * Documentation: [Base UI OTP Field](https://base-ui.com/react/components/otp-field) */ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; export const OTPFieldRoot = /*#__PURE__*/React.forwardRef(function OTPFieldRoot(componentProps, forwardedRef) { const { 'aria-describedby': ariaDescribedByProp, 'aria-labelledby': ariaLabelledByProp, id: idProp, autoComplete = 'one-time-code', defaultValue, value: valueProp, onValueChange, onValueComplete: onValueCompleteProp, form, length, autoSubmit = false, mask = false, inputMode: inputModeProp, validationType = 'numeric', sanitizeValue, disabled: disabledProp = false, readOnly = false, required = false, name: nameProp, onValueInvalid, render, className, style, ...elementProps } = componentProps; const { setDirty, validityData, disabled: fieldDisabled, setFilled, invalid, name: fieldName, state: fieldState, validation, validationMode, shouldValidateOnChange, setFocused, setTouched } = useFieldRootContext(); const { clearErrors } = useFormContext(); const { getDescriptionProps, labelId } = useLabelableContext(); const disabled = fieldDisabled || disabledProp; const name = fieldName ?? nameProp; const [valueUnwrapped, setValueUnwrapped] = useControlled({ controlled: valueProp, default: defaultValue, name: 'OTPField', state: 'value' }); const rootRef = React.useRef(null); const inputRefs = React.useRef([]); const pendingFocusRef = React.useRef(null); const pendingCompleteValueRef = React.useRef(null); const firstInputRef = React.useMemo(() => ({ get current() { return inputRefs.current[0] ?? null; } }), []); const id = useLabelableId({ id: idProp }); const ariaLabelledBy = useAriaLabelledBy(ariaLabelledByProp, labelId, firstInputRef, true, id); const inputAriaLabelledBy = ariaLabelledByProp == null ? ariaLabelledBy : undefined; const fieldDescriptionProps = getDescriptionProps({}); const ariaDescribedBy = mergeAriaIds(fieldDescriptionProps['aria-describedby'], ariaDescribedByProp); const validationConfig = getOTPValidationConfig(validationType); const pattern = validationConfig?.slotPattern; const hiddenInputPattern = validationConfig?.getRootPattern(length); const inputMode = inputModeProp ?? validationConfig?.inputMode; const hasValidLength = Number.isInteger(length) && length > 0; const value = normalizeOTPValue(valueUnwrapped, length, validationType, sanitizeValue); const valueRef = useValueAsRef(value); const filled = value !== ''; const [inputCount, setInputCount] = React.useState(0); const [focusedIndex, setFocusedIndex] = React.useState(() => Math.min(value.length, length - 1)); const [focused, setFocusedState] = React.useState(false); const activeIndex = focused ? Math.min(focusedIndex, Math.max(length - 1, 0)) : Math.min(value.length, length - 1); useIsoLayoutEffect(() => { setFilled(filled); }, [filled, setFilled]); if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks useOTPFieldRootDevWarnings({ inputCount, length, sanitizeValue, validationType }); } useRegisterFieldControl(firstInputRef, { id, getValue: () => valueRef.current, value }); const focusInput = useStableCallback(index => { const targetIndex = Math.min(Math.max(index, 0), Math.max(inputRefs.current.length - 1, 0)); const target = inputRefs.current[targetIndex]; target?.focus(); target?.select(); }); const queueFocusInput = useStableCallback((index, nextValue) => { pendingFocusRef.current = { index, value: nextValue }; }); function requestSubmit() { let formElement = validation.inputRef.current?.form ?? inputRefs.current[0]?.form ?? null; if (form) { const associatedElement = ownerDocument(rootRef.current).getElementById(form); if (associatedElement?.tagName === 'FORM') { formElement = associatedElement; } } if (formElement && typeof formElement.requestSubmit === 'function') { formElement.requestSubmit(); } } useValueChanged(value, () => { clearErrors(name); setDirty(value !== validityData.initialValue); if (shouldValidateOnChange()) { validation.commit(value); } else { validation.commit(value, true); } const pendingCompleteValue = pendingCompleteValueRef.current; if (pendingCompleteValue != null) { pendingCompleteValueRef.current = null; if (pendingCompleteValue.value === value) { onValueCompleteProp?.(value, pendingCompleteValue.eventDetails); if (autoSubmit) { requestSubmit(); } } } const pendingFocus = pendingFocusRef.current; if (pendingFocus != null) { pendingFocusRef.current = null; if (pendingFocus.value === value) { focusInput(pendingFocus.index); } } }); const setValue = useStableCallback((nextValue, details) => { const normalizedValue = normalizeOTPValue(nextValue, length, validationType, sanitizeValue); if (normalizedValue === valueRef.current) { return null; } onValueChange?.(normalizedValue, details); if (details.isCanceled) { return null; } setValueUnwrapped(normalizedValue); if (normalizedValue.length === length && valueRef.current.length !== length) { pendingCompleteValueRef.current = { value: normalizedValue, eventDetails: createGenericEventDetails(details.reason, details.event) }; } else if (normalizedValue.length !== length) { pendingCompleteValueRef.current = null; } return normalizedValue; }); const reportValueInvalid = useStableCallback((invalidValue, details) => { onValueInvalid?.(invalidValue, details); }); const handleInputFocus = useStableCallback((index, event) => { if (index > valueRef.current.length) { focusInput(Math.min(valueRef.current.length, length - 1)); return; } setFocusedIndex(index); setFocusedState(true); setFocused(true); event.currentTarget.select(); }); const handleInputBlur = useStableCallback(event => { if (contains(rootRef.current, event.relatedTarget)) { return; } setTouched(true); setFocusedState(false); setFocused(false); if (validationMode === 'onBlur') { validation.commit(valueRef.current); } }); const getInputId = React.useCallback(index => { if (id == null) { return undefined; } return index === 0 ? id : `${id}-${index + 1}`; }, [id]); const state = React.useMemo(() => ({ ...fieldState, complete: value.length === length, disabled, filled, focused, length, readOnly, required, value }), [disabled, fieldState, filled, focused, length, readOnly, required, value]); const contextValue = React.useMemo(() => ({ autoComplete, activeIndex, disabled, form, focusInput, queueFocusInput, getInputId, handleInputBlur, handleInputFocus, inputMode, inputAriaLabelledBy, invalid, length, mask, pattern, reportValueInvalid, readOnly, required, sanitizeValue, setValue, state, validationType, value }), [activeIndex, autoComplete, disabled, focusInput, form, getInputId, handleInputBlur, handleInputFocus, inputMode, inputAriaLabelledBy, invalid, length, mask, pattern, queueFocusInput, readOnly, reportValueInvalid, required, sanitizeValue, setValue, state, validationType, value]); const element = useRenderElement('div', componentProps, { ref: [forwardedRef, rootRef], state, props: [{ role: 'group', 'aria-describedby': ariaDescribedBy, 'aria-labelledby': ariaLabelledBy }, elementProps], stateAttributesMapping: rootStateAttributesMapping }); return /*#__PURE__*/_jsx(CompositeList, { elementsRef: inputRefs, onMapChange: newMap => { setInputCount(newMap.size); }, children: /*#__PURE__*/_jsxs(OTPFieldRootContext.Provider, { value: contextValue, children: [element, hasValidLength && /*#__PURE__*/_jsx("input", { ...validation.getInputValidationProps({ onFocus() { focusInput(0); }, onChange(event) { if (event.nativeEvent.defaultPrevented) { return; } const rawValue = event.currentTarget.value; const normalizedValue = normalizeOTPValue(rawValue, length, validationType, sanitizeValue); if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { reportValueInvalid(rawValue, createGenericEventDetails(REASONS.inputChange, event.nativeEvent)); } const committedValue = setValue(normalizedValue, createChangeEventDetails(REASONS.inputChange, event.nativeEvent)); if (committedValue != null && committedValue !== '') { queueFocusInput(committedValue.length - 1, committedValue); } } }), ref: validation.inputRef, type: "text", id: id && name == null ? `${id}-hidden-input` : undefined, form: form, name: name, value: value, autoComplete: autoComplete, inputMode: inputMode, minLength: length, maxLength: length, pattern: hiddenInputPattern, disabled: disabled, readOnly: readOnly, required: required, "aria-hidden": true, tabIndex: -1, style: name ? visuallyHiddenInput : visuallyHidden })] }) }); }); if (process.env.NODE_ENV !== "production") OTPFieldRoot.displayName = "OTPFieldRoot"; function mergeAriaIds(...values) { const ids = values.flatMap(value => value?.split(/\s+/).filter(Boolean) ?? []); return ids.length > 0 ? Array.from(new Set(ids)).join(' ') : undefined; } function useOTPFieldRootDevWarnings(parameters) { const { inputCount, length, sanitizeValue, validationType } = parameters; React.useEffect(() => { if (!Number.isInteger(length) || length <= 0 || inputCount === 0 || inputCount === length) { return; } const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; const message = '<OTPField.Root> `length` must match the number of rendered ' + `<OTPField.Input /> parts. Received \`length={${length}}\` but rendered ` + `${inputCount} input${inputCount === 1 ? '' : 's'}.`; warn(message, ownerStackMessage); }, [inputCount, length]); React.useEffect(() => { if (Number.isInteger(length) && length > 0) { return; } const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; warn(`<OTPField.Root> \`length\` must be a positive integer. Received \`length={${String(length)}}\`.`, ownerStackMessage); }, [length]); React.useEffect(() => { if (sanitizeValue == null || validationType === 'none') { return; } const ownerStackMessage = SafeReact.captureOwnerStack?.() || ''; warn('<OTPField.Root> `sanitizeValue` is only used when `validationType="none"`.', ownerStackMessage); }, [sanitizeValue, validationType]); }