import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createRenderEffect, createSignal, omit } from "solid-js"; import { createChangeEventDetails, createGenericEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { CompositeList } from "../../internals/composite/composite-list.tsx"; import { createControlledState } from "../../internals/create-controlled-state"; import { contains } from "../../internals/dom/contains.ts"; import { ownerDocument } from "../../internals/dom/owner-document.ts"; import { useFieldRootContext } from "../../internals/field-context"; import { fieldStateAttrs } from "../../internals/field-state-attrs"; import { useFormContext } from "../../internals/form-context"; import { useAriaLabelledBy } from "../../internals/labelable-provider/use-aria-labelled-by.ts"; import { useLabelableId } from "../../internals/labelable-provider/use-labelable-id.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { ariaDescribedBy as seedAriaDescribedBy, ariaLabelledBy as seedAriaLabelledBy, } from "../../internals/seed-aria-attr"; import { createStableCallback } from "../../internals/stable-callback"; import type { BaseUIComponentProps } from "../../internals/types"; import { useFieldControlValidation } from "../../internals/use-field-control-validation/use-field-control-validation.ts"; import { useValueChanged } from "../../internals/use-value-changed.ts"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style.ts"; import { getOTPValidationConfig, normalizeOTPValue, type OTPValidationType, stripOTPWhitespace, } from "../utils/otp.ts"; import { OTPFieldRootContext, type OTPFieldRootContextValue, } from "./otp-field-root-context.ts"; import type { OTPFieldRootChangeEventDetails, OTPFieldRootChangeEventReason, OTPFieldRootCompleteEventDetails, OTPFieldRootInvalidEventDetails, OTPFieldRootState, OTPFieldValidationType, } from "./otp-field-root-types.ts"; export type { OTPFieldRootChangeEventDetails, OTPFieldRootChangeEventReason, OTPFieldRootCompleteEventDetails, OTPFieldRootInvalidEventDetails, OTPFieldRootState, OTPFieldValidationType, }; const VISUALLY_HIDDEN_INPUT_STYLE: JSX.CSSProperties = { ...VISUALLY_HIDDEN_STYLE, padding: "1px", }; export interface OTPFieldRootProps extends Omit< BaseUIComponentProps<"div", OTPFieldRootState>, "onChange" | "value" > { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; id?: string | undefined; autoComplete?: string | undefined; form?: string | undefined; length: number; autoSubmit?: boolean | undefined; mask?: boolean | undefined; inputMode?: | JSX.InputHTMLAttributes["inputmode"] | undefined; validationType?: OTPFieldValidationType | undefined; sanitizeValue?: ((value: string) => string) | undefined; required?: boolean | undefined; disabled?: boolean | undefined; readOnly?: boolean | undefined; name?: string | undefined; /** * Controlled OTP value. While not `undefined` the consumer owns the * state โ€” pair with {@link onValueChange}. When omitted, the field * manages its own value (seeded from {@link defaultValue}), so typing * distributes digits across slots with zero wiring. */ value?: string | undefined; /** * The uncontrolled OTP value when the component is initially rendered. * Read once at mount. To render a controlled OTP field, use the `value` * prop instead. * @default "" */ defaultValue?: string | undefined; onValueChange?: | ((value: string, details: OTPFieldRootChangeEventDetails) => void) | undefined; onValueInvalid?: | ((value: string, details: OTPFieldRootInvalidEventDetails) => void) | undefined; onValueComplete?: | ((value: string, details: OTPFieldRootCompleteEventDetails) => void) | undefined; } /** * Groups all `` slots and manages the shared OTP value, focus * coordination, clipboard distribution, validation and form integration. * Renders a `
` (or `as` override). * * ยง1 wave 2: replaces `renderElement` with `dynamic(() => props.as ?? "div")`. * Field-validity attrs flow through `fieldStateAttrs(state)`; OTP-specific * presence attrs (`data-complete`, `data-disabled`, `data-readonly`, * `data-required`) are emitted via inline reactive getters on the internal * slot bag. The `seedAriaLabelledBy`/`seedAriaDescribedBy` helpers and the * field-validation a11y bag compose via `mergeRootProps`. */ export function OTPFieldRoot(props: OTPFieldRootProps): JSX.Element { const field = useFieldRootContext(); const form = useFormContext(); const disabled = (): boolean => field.disabled() || props.disabled === true; const readOnly = (): boolean => props.readOnly === true; const required = (): boolean => props.required === true; const mask = (): boolean => props.mask === true; const autoSubmit = (): boolean => props.autoSubmit === true; const length = (): number => props.length; const validationType = (): OTPValidationType => props.validationType ?? "numeric"; const autoComplete = (): string | undefined => props.autoComplete ?? "one-time-code"; const sanitizeValue = (): ((value: string) => string) | undefined => props.sanitizeValue; const name = (): string | undefined => field.name() ?? props.name; const formId = (): string | undefined => props.form; const { id: rootId } = useLabelableId({ id: typeof props.id === "string" ? props.id : undefined, }); // Controlled / uncontrolled value (Base UI `value` / `defaultValue` // contract): the `value` prop wins while not `undefined` (an explicit // `""` IS a controlled empty value); otherwise an internal signal // (seeded once from `defaultValue`, default `""`) backs the state and // `setValue` below writes it. const [valueUnwrapped, setUncontrolledValue] = createControlledState({ controlled: () => props.value, default: () => props.defaultValue, fallback: "", name: "", state: "value", }); const value = createMemo((): string => normalizeOTPValue( valueUnwrapped(), length(), validationType(), sanitizeValue(), ), ); const rootRef: { current: HTMLDivElement | null } = { current: null }; const elementsRef: Array = []; let pendingFocus: { index: number; value: string } | null = null; let pendingCompleteValue: { value: string; eventDetails: OTPFieldRootCompleteEventDetails; } | null = null; const firstInputRef: { current: HTMLInputElement | null } = { get current() { return (elementsRef[0] as HTMLInputElement | null) ?? null; }, set current(_value) { // Read-only shim โ€” CompositeList owns `elementsRef` mutations. }, }; const ariaLabelledBy = useAriaLabelledBy((): string | undefined => typeof props["aria-labelledby"] === "string" ? props["aria-labelledby"] : undefined, ); const inputAriaLabelledBy = createMemo((): string | undefined => { if (typeof props["aria-labelledby"] === "string") return undefined; return ariaLabelledBy(); }); const validationConfig = createMemo(() => getOTPValidationConfig(validationType()), ); const pattern = (): string | undefined => validationConfig()?.slotPattern ?? undefined; const hiddenInputPattern = (): string | undefined => { const cfg = validationConfig(); return cfg ? cfg.getRootPattern(length()) : undefined; }; const inputMode = (): JSX.InputHTMLAttributes["inputmode"] => props.inputMode ?? validationConfig()?.inputMode; const hasValidLength = (): boolean => { const l = length(); return Number.isInteger(l) && l > 0; }; const filled = (): boolean => value() !== ""; const [focusedIndex, setFocusedIndex] = createSignal(0); const [focused, setFocusedState] = createSignal(false); const [inputCount, setInputCount] = createSignal(0, { ownedWrite: true }); if (process.env.NODE_ENV !== "production") { createRenderEffect( () => ({ count: inputCount(), len: length(), sanitize: sanitizeValue(), vtype: validationType(), }), ({ count, len, sanitize, vtype }) => { if (!Number.isInteger(len) || len <= 0) { console.warn( `Base UI: \`length\` must be a positive integer. Received \`length={${String(len)}}\`.`, ); } else if (count !== 0 && count !== len) { console.warn( `Base UI: \`length\` must match the number of rendered parts. Received \`length={${len}}\` but rendered ${count} input${count === 1 ? "" : "s"}.`, ); } if (sanitize != null && vtype !== "none") { console.warn( 'Base UI: `sanitizeValue` is only used when `validationType="none"`.', ); } }, ); } const activeIndex = createMemo((): number => { const l = length(); if (focused()) { return Math.min(focusedIndex(), Math.max(l - 1, 0)); } return Math.min(value().length, Math.max(l - 1, 0)); }); const controlRef: { current: HTMLElement | null } = { get current() { return firstInputRef.current; }, set current(_v) { // No-op โ€” the first slot input is the registered control. }, }; const validation = useFieldControlValidation({ value: () => value(), id: () => rootId, controlRef, getValue: () => value(), }); useValueChanged(filled, () => { field.setFilled(filled()); }); useValueChanged(value, () => { const current = value(); field.setDirty(current !== field.validityData().initialValue); if (field.shouldValidateOnChange()) { void validation.commit(current); } else { void validation.commit(current, true); } const pendingComplete = pendingCompleteValue; if (pendingComplete != null) { pendingCompleteValue = null; if (pendingComplete.value === current) { props.onValueComplete?.(current, pendingComplete.eventDetails); if (autoSubmit()) requestSubmit(); } } const pendingFocusSnapshot = pendingFocus; if (pendingFocusSnapshot != null) { pendingFocus = null; if (pendingFocusSnapshot.value === current) { focusInput(pendingFocusSnapshot.index); } } }); const focusInput = createStableCallback<(index: number) => void>( () => (index) => { const max = Math.max(elementsRef.length - 1, 0); const targetIndex = Math.min(Math.max(index, 0), max); const target = elementsRef[targetIndex] as HTMLInputElement | null; target?.focus(); target?.select?.(); }, ); const queueFocusInput = createStableCallback< (index: number, nextValue: string) => void >(() => (index, nextValue) => { pendingFocus = { index, value: nextValue }; }); function requestSubmit(): void { let formElement: HTMLFormElement | null = validation.inputRef.current?.form ?? (elementsRef[0] as HTMLInputElement | null)?.form ?? null; const formIdValue = formId(); if (formIdValue) { const doc = ownerDocument(rootRef.current); const associatedElement = doc.getElementById(formIdValue); if (associatedElement?.tagName === "FORM") { formElement = associatedElement as HTMLFormElement; } } if (formElement && typeof formElement.requestSubmit === "function") { formElement.requestSubmit(); } } useValueChanged(value, () => { const currentName = name(); if (currentName != null && form.clearErrors) { form.clearErrors(currentName); } }); const setValue = createStableCallback< ( nextValue: string, details: OTPFieldRootChangeEventDetails, ) => string | null >(() => (nextValue, details) => { const normalizedValue = normalizeOTPValue( nextValue, length(), validationType(), sanitizeValue(), ); const previous = value(); if (normalizedValue === previous) return null; props.onValueChange?.(normalizedValue, details); // Cancel gate matches React Base UI `OTPFieldRoot.setValue`: // `details.cancel()` keeps the uncontrolled state unchanged, queues // no pending complete, and returns `null` so callers skip the // focus-advance bookkeeping. if (details.isCanceled) return null; setUncontrolledValue(normalizedValue); const l = length(); if (normalizedValue.length === l && previous.length !== l) { pendingCompleteValue = { value: normalizedValue, eventDetails: createGenericEventDetails( details.reason as | (typeof REASONS)["inputChange"] | (typeof REASONS)["inputPaste"], details.event as ClipboardEvent | InputEvent | Event, ), }; } else if (normalizedValue.length !== l) { pendingCompleteValue = null; } return normalizedValue; }); const reportValueInvalid = createStableCallback< (invalidValue: string, details: OTPFieldRootInvalidEventDetails) => void >(() => (invalidValue, details) => { props.onValueInvalid?.(invalidValue, details); }); const handleInputFocus = createStableCallback< ( index: number, event: FocusEvent & { currentTarget: HTMLInputElement }, ) => void >(() => (index, event) => { const currentValue = value(); if (index > currentValue.length) { focusInput(Math.min(currentValue.length, length() - 1)); return; } setFocusedIndex(index); setFocusedState(true); field.setFocused(true); event.currentTarget.select(); }); const handleInputBlur = createStableCallback< (event: FocusEvent & { currentTarget: HTMLInputElement }) => void >(() => (event) => { const relatedTarget = event.relatedTarget as Element | null; if (contains(rootRef.current, relatedTarget)) return; field.setTouched(true); setFocusedState(false); field.setFocused(false); if (field.validationMode() === "onBlur") { void validation.commit(value()); } }); const getInputId = (index: number): string | undefined => { if (rootId == null) return undefined; return index === 0 ? rootId : `${rootId}-${index + 1}`; }; const state = createMemo( (): OTPFieldRootState => ({ ...field.state(), complete: value().length === length(), disabled: disabled(), filled: filled(), focused: focused(), length: length(), readOnly: readOnly(), required: required(), value: value(), }), ); const contextValue: OTPFieldRootContextValue = { activeIndex, autoComplete, disabled, form: formId, focusInput, queueFocusInput, getInputId, handleInputBlur, handleInputFocus, inputMode, inputAriaLabelledBy, invalid: (): boolean => field.invalid(), length, mask, pattern, reportValueInvalid, readOnly, required, sanitizeValue, setValue, state, validationType: () => validationType(), value, present: true, }; const userStyling = resolveClassStyle(props, () => state()); const userProps = omit( props, "class", "style", "attrs", "as", "autoComplete", "autoSubmit", "children", "defaultValue", "disabled", "form", "id", "inputMode", "length", "mask", "name", "onValueChange", "onValueComplete", "onValueInvalid", "readOnly", "required", "sanitizeValue", "validationType", "value", "aria-labelledby", "aria-describedby", ); const ariaDescribedBy = (): string | undefined => typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined; const internalSlot = { id: rootId, role: "group" as const, get "data-complete"(): "" | undefined { return value().length === length() ? "" : undefined; }, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, get "data-readonly"(): "" | undefined { return readOnly() ? "" : undefined; }, get "data-required"(): "" | undefined { return required() ? "" : undefined; }, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, seedAriaLabelledBy(ariaLabelledBy) ?? {}, ); merged = mergeRootProps(merged, seedAriaDescribedBy(ariaDescribedBy) ?? {}); merged = mergeRootProps(merged, validation.getValidationProps() ?? {}); merged = mergeRootProps(merged, fieldStateAttrs(state)); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( { rootRef.current = el as HTMLDivElement | null; }, userRef(props))} > elementsRef={elementsRef} onMapChange={(map) => { setInputCount(map.size); }} > {props.children} rootId} hasName={() => name() != null} inputMode={inputMode} length={length} name={name} pattern={hiddenInputPattern} readOnly={readOnly} required={required} reportValueInvalid={reportValueInvalid} sanitizeValue={sanitizeValue} setValue={setValue} validation={validation} validationType={validationType} value={value} focusFirstInput={() => focusInput(0)} queueFocusInput={queueFocusInput} /> ); } interface HiddenValidationInputProps { enabled: () => boolean; autoComplete: () => string | undefined; disabled: () => boolean; form: () => string | undefined; getRootId: () => string | undefined; hasName: () => boolean; inputMode: () => JSX.InputHTMLAttributes["inputmode"]; length: () => number; name: () => string | undefined; pattern: () => string | undefined; readOnly: () => boolean; required: () => boolean; reportValueInvalid: ( value: string, details: OTPFieldRootInvalidEventDetails, ) => void; sanitizeValue: () => ((value: string) => string) | undefined; setValue: ( value: string, details: OTPFieldRootChangeEventDetails, ) => string | null; validation: ReturnType; validationType: () => OTPValidationType; value: () => string; focusFirstInput: () => void; queueFocusInput: (index: number, value: string) => void; } function HiddenValidationInput(props: HiddenValidationInputProps): JSX.Element { if (!props.enabled()) return null; const id = (): string | undefined => { const rid = props.getRootId(); return rid && !props.hasName() ? `${rid}-hidden-input` : undefined; }; const hiddenStyle = (): JSX.CSSProperties => props.hasName() ? VISUALLY_HIDDEN_INPUT_STYLE : { ...VISUALLY_HIDDEN_STYLE }; const inputValidationProps = props.validation.getInputValidationProps({ onFocus: (): void => { props.focusFirstInput(); }, onChange: (event: Event & { currentTarget: HTMLInputElement }): void => { if (event.defaultPrevented) return; const rawValue = event.currentTarget.value; const normalizedValue = normalizeOTPValue( rawValue, props.length(), props.validationType(), props.sanitizeValue(), ); if (stripOTPWhitespace(rawValue).length > normalizedValue.length) { props.reportValueInvalid( rawValue, createGenericEventDetails(REASONS.inputChange, event), ); } const committed = props.setValue( normalizedValue, createChangeEventDetails(REASONS.inputChange, event), ); if (committed != null && committed !== "") { props.queueFocusInput(committed.length - 1, committed); } }, }); const ref = (el: HTMLInputElement | null): void => { props.validation.inputRef.current = el; }; return ( // biome-ignore lint/a11y/noAriaHiddenOnFocusable: mirrors React Base UI's hidden validation input. )} ref={ref} type="text" id={id()} form={props.form()} name={props.name()} value={props.value()} autocomplete={props.autoComplete() ?? "one-time-code"} inputmode={props.inputMode()} minlength={props.length()} maxlength={props.length()} pattern={props.pattern()} disabled={props.disabled() || undefined} readonly={props.readOnly() || undefined} required={props.required() || undefined} aria-hidden="true" tabindex={-1} style={hiddenStyle()} /> ); }