@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
JavaScript
'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]);
}