import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createSignal, omit, untrack } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { userRef } from "../../internals/compose-refs"; import { createControlledState } from "../../internals/create-controlled-state"; import { activeElement } from "../../internals/dom/active-element.ts"; import { ownerDocument } from "../../internals/dom/owner-document.ts"; import type { FieldRootState } from "../../internals/field-constants"; import { useFieldRootContext } from "../../internals/field-context"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect.ts"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style"; import { createNumberField } from "../create-number-field.tsx"; import { DEFAULT_STEP } from "../utils/constants.ts"; import { numberFieldDataAttrs } from "../utils/state-attributes-mapping.ts"; import { type NumberFieldRootChangeEventDetails, type NumberFieldRootCommitEventDetails, NumberFieldRootContext, type NumberFieldRootContextValue, } from "./number-field-root-context.ts"; export type { NumberFieldRootChangeEventDetails, NumberFieldRootChangeEventReason, NumberFieldRootCommitEventDetails, NumberFieldRootCommitEventReason, } from "./number-field-root-context.ts"; /** Overall state used by every `` part's `data-*` projection. */ export interface NumberFieldRootState extends FieldRootState { value: number | null; inputValue: string; required: boolean; disabled: boolean; readOnly: boolean; scrubbing: boolean; } export interface NumberFieldRootProps extends Omit, "onChange"> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; id?: string | undefined; min?: MaybeAccessor; max?: MaybeAccessor; /** * When true, direct text entry may be outside the `min`/`max` range * without clamping, so native range underflow/overflow validation can * occur. Step-based interactions (keyboard arrows, buttons, wheel, * scrub) still clamp. * @default false */ allowOutOfRange?: MaybeAccessor; /** @default 0.1 */ smallStep?: MaybeAccessor; /** @default 1 */ step?: MaybeAccessor; /** @default 10 */ largeStep?: MaybeAccessor; /** @default false */ required?: boolean | undefined; /** @default false */ disabled?: boolean | undefined; /** @default false */ readOnly?: boolean | undefined; name?: string | undefined; form?: string | undefined; /** * Controlled value. `null` means empty. While its resolved value is not * `undefined` the consumer owns the state — pair with * {@link onValueChange}. When omitted, the field manages its own value * (seeded from {@link defaultValue}). */ value?: MaybeAccessor; /** * Initial value when uncontrolled. `null` means empty. Read once at * mount. To render a controlled number field, use the `value` prop * instead. * @default null */ defaultValue?: MaybeAccessor; /** @default false */ allowWheelScrub?: boolean | undefined; /** @default false */ snapOnStep?: MaybeAccessor; format?: MaybeAccessor; onValueChange?: | (( value: number | null, eventDetails: NumberFieldRootChangeEventDetails, ) => void) | undefined; onValueCommitted?: | (( value: number | null, eventDetails: NumberFieldRootCommitEventDetails, ) => void) | undefined; locale?: MaybeAccessor; /** A setter to capture the hidden input element. */ inputRef?: ((element: HTMLInputElement | null) => void) | undefined; } /** * Groups all parts of the number field and manages its state. Renders a * `
`. * * Logical state (value normalization, step math, formatting, increment / * decrement) is owned by `createNumberField`. This wrapper retains DOM * concerns: field registration, hidden input, wheel listener, `disabled` / * `readOnly` / `required` resolved against the ``, and the * shared mutable refs that child event handlers need synchronously. */ export function NumberFieldRoot(props: NumberFieldRootProps): JSX.Element { const field = useFieldRootContext(); const id = useBaseUiId( untrack(() => (typeof props.id === "string" ? props.id : undefined)), ); // DOM-concern prop thunks. `disabled` depends on the field context so it // stays here. `name` is similarly field-aware. const disabled = (): boolean => field.disabled() || untrack(() => props.disabled === true); const readOnly = (): boolean => untrack(() => props.readOnly === true); const required = (): boolean => untrack(() => props.required === true); const name = (): string | undefined => field.name() ?? untrack(() => props.name); const invalid = (): boolean => field.invalid(); // `stepProp` preserves the "any" sentinel for the hidden input's `step` // attribute. The primitive resolves "any" → 1 for numeric math. const stepProp = (): number | "any" => untrack(() => readOption(props.step) ?? 1); // IME guard: set to `false` by `` during composition to // suppress mid-composition input-value flicker. Read synchronously by the // primitive's `setValue` via `shouldSyncInput`. const allowInputSyncRef = { current: true }; // Mutable refs read synchronously from event handlers in child parts. const lastChangedValueRef: { current: number | null } = { current: null }; const hasPendingCommitRef = { current: false }; const formatOptionsRef: { current: Intl.NumberFormatOptions | undefined; } = { current: untrack(() => readOption(props.format)) }; // Shared input ref — `` writes its element here; the // hidden input's onFocus redirects focus to it. const inputRef: { current: HTMLInputElement | null } = { current: null }; const hiddenInputRef: { current: HTMLInputElement | null } = { current: null, }; // Keep `formatOptionsRef` synced so event-handler closures always read // the current format options. useIsoLayoutEffect( () => readOption(props.format), (next) => { formatOptionsRef.current = next; }, ); // Controlled / uncontrolled value (Base UI `value` / `defaultValue` // contract): the `value` prop wins while its resolved value is defined // (an explicit `null` IS a controlled empty value); otherwise an internal // signal (seeded once from `defaultValue`, default `null`) backs the // state and the change flow below writes it. const [resolvedValue, setUncontrolledValue] = createControlledState< number | null >({ controlled: () => readOption(props.value), default: () => readOption(props.defaultValue), fallback: null, name: "", state: "value", }); // Logical state primitive. The `shouldSyncInput` bridge threads the IME // guard so the primitive skips input-value formatting during composition. const nf = createNumberField({ value: resolvedValue, smallStep: () => readOption(props.smallStep), step: () => readOption(props.step), largeStep: () => readOption(props.largeStep), min: () => readOption(props.min), max: () => readOption(props.max), allowOutOfRange: () => readOption(props.allowOutOfRange), snapOnStep: () => readOption(props.snapOnStep), format: () => readOption(props.format), locale: () => readOption(props.locale), shouldSyncInput: () => allowInputSyncRef.current, onValueChange: (next, details) => { lastChangedValueRef.current = next; props.onValueChange?.(next, details); // Cancel gate matches React Base UI `NumberFieldRoot.setValue`: // `details.cancel()` keeps the uncontrolled state, dirtiness, and // pending-commit bookkeeping unchanged (input sync is skipped by // the primitive's own `isCanceled` check). if (details.isCanceled) return; setUncontrolledValue(next); field.setDirty(next !== field.validityData().initialValue); hasPendingCommitRef.current = true; }, onValueCommitted: (next, details) => { hasPendingCommitRef.current = false; props.onValueCommitted?.(next, details); }, }); // Mutable value ref — child event handlers read this synchronously to // avoid a stale-closure read of the prop. const valueRef: { current: number | null } = { current: untrack(nf.value), }; useIsoLayoutEffect(nf.value, (next) => { valueRef.current = next; }); // Keep `field.filled` in sync. useIsoLayoutEffect(nf.value, (next) => { field.setFilled(next !== null); }); const [isScrubbing, setIsScrubbing] = createSignal(false, { ownedWrite: true, }); // Wheel listener — `onWheel` in JSX can't call `preventDefault()`. useIsoLayoutEffect( () => [ disabled(), readOnly(), props.allowWheelScrub, inputRef.current, ] as const, ([isDisabled, isReadOnly, allowScrub, element]) => { if (isDisabled || isReadOnly || !allowScrub || !element) { return undefined; } function handleWheel(event: WheelEvent): void { if ( event.ctrlKey || activeElement(ownerDocument(element)) !== element ) { return; } event.preventDefault(); const amount = nf.getStepAmount(event) ?? DEFAULT_STEP; nf.increment(amount, { direction: event.deltaY > 0 ? -1 : 1, event, reason: REASONS.wheel, }); } element.addEventListener("wheel", handleWheel, { passive: false }); return () => { element.removeEventListener("wheel", handleWheel); }; }, ); const state = createMemo( (): NumberFieldRootState => ({ ...field.state(), disabled: disabled(), readOnly: readOnly(), required: required(), value: nf.value(), inputValue: nf.inputValue(), scrubbing: isScrubbing(), }), ); const contextValue: NumberFieldRootContextValue = { inputRef, inputValue: nf.inputValue, value: nf.value, minWithDefault: nf.minWithDefault, maxWithDefault: nf.maxWithDefault, disabled, readOnly, id, setValue: nf.setValue, incrementValue: nf.increment, getStepAmount: nf.getStepAmount, allowInputSyncRef, formatOptionsRef, valueRef, lastChangedValueRef, hasPendingCommitRef, name, required, invalid, inputMode: nf.inputMode, getAllowedNonNumericKeys: nf.getAllowedNonNumericKeys, min: nf.min, max: nf.max, setInputValue: nf.setInputValue, locale: nf.locale, isScrubbing, setIsScrubbing: (next: boolean) => { setIsScrubbing(next); }, state, onValueCommitted: nf.commitValue, }; const userStyling = resolveClassStyle(props, () => state()); const userProps = omit( props, "class", "style", "attrs", "as", "allowOutOfRange", "allowWheelScrub", "children", "defaultValue", "disabled", "form", "format", "inputRef", "largeStep", "locale", "max", "min", "name", "onValueChange", "onValueCommitted", "readOnly", "required", "smallStep", "snapOnStep", "step", "value", ); const internalSlot = { get children() { return ( {props.children} {/* The hidden input combines `tabindex={-1}` with `aria-hidden="true"` so it stays out of the tab order and AT tree (matches React Base UI). biome-ignore lint/a11y/noAriaHiddenOnFocusable: the `tabindex={-1}` removes the element from focus; the redundant `aria-hidden` mirrors React for belt-and-braces AT coverage. */} { hiddenInputRef.current = el; // Bridge the hidden input into the field // validation pipeline so native validity // (rangeUnderflow / stepMismatch / etc.) flows // through the same path as ``. field.validation.inputRef.current = el; props.inputRef?.(el); }} type="number" form={props.form} name={name()} value={nf.value() ?? ""} min={readOption(props.min)} max={readOption(props.max)} step={String(stepProp())} disabled={disabled()} required={required()} aria-hidden="true" tabindex={-1} style={{ ...VISUALLY_HIDDEN_STYLE } as JSX.CSSProperties} onFocus={() => { inputRef.current?.focus(); }} onInput={( event: InputEvent & { currentTarget: HTMLInputElement; }, ) => { const nextValue = event.currentTarget.valueAsNumber; const parsedValue = Number.isNaN(nextValue) ? null : nextValue; const details = createChangeEventDetails(REASONS.none, event); field.setDirty(parsedValue !== field.validityData().initialValue); nf.setValue(parsedValue, details); if (field.shouldValidateOnChange()) { void field.validation.commit(parsedValue); } }} /> ); }, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, numberFieldDataAttrs(state), ); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ; } export namespace NumberFieldRoot { export type State = NumberFieldRootState; export type Props = NumberFieldRootProps; export type ChangeEventDetails = NumberFieldRootChangeEventDetails; export type CommitEventDetails = NumberFieldRootCommitEventDetails; }