import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, createSignal, runWithOwner, untrack, useContext, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../internals/change-event-details"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import { useIsoLayoutEffect } from "../internals/use-iso-layout-effect.ts"; import type { NumberFieldRootChangeEventDetails, NumberFieldRootCommitEventDetails, } from "./root/number-field-root-context.ts"; import { DEFAULT_STEP } from "./utils/constants.ts"; import { formatNumber, formatNumberMaxPrecision, } from "./utils/format-number.ts"; import { BASE_NON_NUMERIC_SYMBOLS, getNumberLocaleDetails, MINUS_SIGNS_WITH_ASCII, PERCENTAGES, PERMILLE, PLUS_SIGNS_WITH_ASCII, SPACE_SEPARATOR_RE, } from "./utils/parse.ts"; import type { EventWithOptionalKeyState, IncrementValueParameters, } from "./utils/types.ts"; import { toValidatedNumber } from "./utils/validate.ts"; export type { NumberFieldRootChangeEventDetails, NumberFieldRootCommitEventDetails, }; export type NumberFieldChangeReason = | typeof REASONS.inputChange | typeof REASONS.inputClear | typeof REASONS.inputBlur | typeof REASONS.inputPaste | typeof REASONS.keyboard | typeof REASONS.incrementPress | typeof REASONS.decrementPress | typeof REASONS.wheel | typeof REASONS.scrub | typeof REASONS.none; export type NumberFieldCommitReason = | typeof REASONS.inputBlur | typeof REASONS.inputClear | typeof REASONS.keyboard | typeof REASONS.incrementPress | typeof REASONS.decrementPress | typeof REASONS.wheel | typeof REASONS.scrub | typeof REASONS.none; export type NumberFieldInputMode = "numeric" | "decimal" | "text"; /** * Options for {@link createNumberField}. * * All reactive inputs accept `MaybeAccessor`. No internal state — the * consumer's signal is the sole source of truth. */ export interface CreateNumberFieldOptions { /** Consumer-owned value. `null` means empty. */ value: MaybeAccessor; /** @default 0.1 */ smallStep?: MaybeAccessor; /** @default 1 */ step?: MaybeAccessor; /** @default 10 */ largeStep?: MaybeAccessor; min?: MaybeAccessor; max?: MaybeAccessor; /** * When true, keyboard/button/wheel changes still clamp, but direct text * entry may exceed the range so native `rangeUnderflow`/`rangeOverflow` * validation can fire. * @default false */ allowOutOfRange?: MaybeAccessor; /** @default false */ snapOnStep?: MaybeAccessor; format?: MaybeAccessor; locale?: MaybeAccessor; /** * Pure event-emitter. The consumer's signal write is the only writer. * Use `runWithOwner(null, ...)` pattern — already applied internally. */ onValueChange?: ( value: number | null, details: NumberFieldRootChangeEventDetails, ) => void; /** * Guard checked before syncing `inputValue` after a `setValue` call. * Pass `() => allowInputSyncRef.current` from the wrapper to suppress * input sync during IME composition. */ shouldSyncInput?: MaybeAccessor; onValueCommitted?: ( value: number | null, details: NumberFieldRootCommitEventDetails, ) => void; } /** * Public API returned by {@link createNumberField}. */ export interface NumberFieldApi { /** Consumer-supplied value (memoized, null = empty). */ value: Accessor; /** Formatted display string for the visible input. */ inputValue: Accessor; /** Resolved `min` (defaults to `Number.MIN_SAFE_INTEGER`). */ minWithDefault: Accessor; /** Resolved `max` (defaults to `Number.MAX_SAFE_INTEGER`). */ maxWithDefault: Accessor; /** Raw `min` (undefined if not set). */ min: Accessor; /** Raw `max` (undefined if not set). */ max: Accessor; /** Resolved step amount (never "any" — "any" coerces to 1). */ step: Accessor; /** Small-step amount (Alt+Arrow). */ smallStep: Accessor; /** Large-step amount (Shift+Arrow). */ largeStep: Accessor; /** `inputmode` attribute for the visible input (iOS-aware). */ inputMode: Accessor; /** * Validated + formatted write. Returns whether a change was emitted. * Validation, clamping, step-snap, and formatting are applied before * `onValueChange` fires. */ setValue: ( raw: number | null, details: NumberFieldRootChangeEventDetails, ) => boolean; /** * Step-based increment / decrement. Adds `amount * direction` to the * current value (or `max(0, min)` when empty) then validates. */ increment: (amount: number, params: IncrementValueParameters) => boolean; /** Fire `onValueCommitted`. */ commitValue: ( next: number | null, details: NumberFieldRootCommitEventDetails, ) => void; /** * Step amount for a given keyboard event — `smallStep` (Alt), `largeStep` * (Shift), or `step`. */ getStepAmount: (event?: EventWithOptionalKeyState) => number | undefined; /** * Set of non-numeric characters the visible input should accept. Computed * from locale + format options. */ getAllowedNonNumericKeys: () => Set; /** Resolved locale (undefined = runtime default). */ locale: Accessor; /** * Override the displayed input string. Called by `` to * hold partial input while editing (e.g. mid-entry decimal). */ setInputValue: (next: string) => void; /** * Context provider. Wrap a subtree to make the api accessible via * `useNumberField()` without prop-drilling. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } const NumberFieldContext = createContext(null); /** * Read the nearest {@link NumberFieldApi} provided by `c.Provider`. Throws if * called outside a Provider. */ export function useNumberField(): NumberFieldApi { const ctx = useContext(NumberFieldContext); if (ctx == null) { throw new Error( "Base UI: useNumberField() must be called inside a .", ); } return ctx; } function getControlledInputValue( value: number | null, locale: Intl.LocalesArgument | undefined, format: Intl.NumberFormatOptions | undefined, ): string { const explicitPrecision = format?.maximumFractionDigits != null || format?.minimumFractionDigits != null; return explicitPrecision ? formatNumber(value, locale, format) : formatNumberMaxPrecision(value, locale, format); } const IS_IOS = typeof navigator !== "undefined" && /iP(ad|hone|od)/.test(navigator.platform ?? ""); /** * Solid 2.0-native headless primitive for a number field. Owns logical * state: value normalization, step math, formatting, and increment/decrement. * DOM concerns (visible input ref, hidden input, wheel listener, field * validation wiring) stay in ``. */ export function createNumberField( options: CreateNumberFieldOptions, ): NumberFieldApi { const value = createMemo( () => readOption(options.value) ?? null, { name: "numberField.value" }, ); const smallStep = createMemo( () => readOption(options.smallStep) ?? 0.1, { name: "numberField.smallStep" }, ); const largeStep = createMemo( () => readOption(options.largeStep) ?? 10, { name: "numberField.largeStep" }, ); const stepRaw = createMemo( () => readOption(options.step) ?? 1, { name: "numberField.step" }, ); const step = createMemo( () => { const s = stepRaw(); return s === "any" ? 1 : s; }, { name: "numberField.stepResolved" }, ); const min = createMemo(() => readOption(options.min), { name: "numberField.min", }); const max = createMemo(() => readOption(options.max), { name: "numberField.max", }); const minWithDefault = createMemo( () => min() ?? Number.MIN_SAFE_INTEGER, { name: "numberField.minWithDefault" }, ); const maxWithDefault = createMemo( () => max() ?? Number.MAX_SAFE_INTEGER, { name: "numberField.maxWithDefault" }, ); const minWithZeroDefault = createMemo(() => min() ?? 0, { name: "numberField.minWithZeroDefault", }); const format = createMemo( () => readOption(options.format), { name: "numberField.format" }, ); const locale = createMemo( () => readOption(options.locale), { name: "numberField.locale" }, ); const allowOutOfRange = createMemo( () => readOption(options.allowOutOfRange) === true, { name: "numberField.allowOutOfRange" }, ); const snapOnStep = createMemo( () => readOption(options.snapOnStep) === true, { name: "numberField.snapOnStep" }, ); // Visible input text. Initialized from the starting value. const [inputValue, setInputValueState] = createSignal( untrack(() => getControlledInputValue(value(), locale(), format())), { ownedWrite: true, name: "numberField.inputValue" }, ); const [inputMode, setInputMode] = createSignal( "numeric", { ownedWrite: true, name: "numberField.inputMode" }, ); // Keep inputMode in sync for iOS. if (IS_IOS) { createMemo(() => { const lo = minWithDefault(); const newMode: NumberFieldInputMode = lo >= 0 ? "decimal" : "text"; setInputMode(newMode); }); } // Sync inputValue whenever the external value / locale / format changes // (e.g. consumer writes their signal from outside). The shouldSyncInput // guard suppresses this during IME composition. useIsoLayoutEffect( () => [value(), locale(), format()] as const, () => untrack(() => { const shouldSync = readOption(options.shouldSyncInput) ?? true; if (!shouldSync) return undefined; const current = value(); const nextInputValue = getControlledInputValue( current, locale(), format(), ); if (nextInputValue !== inputValue()) { setInputValueState(nextInputValue); } return undefined; }), ); // Mutable ref — read synchronously from event handlers. const formatOptionsRef: { current: Intl.NumberFormatOptions | undefined } = { current: untrack(format), }; function getStepAmount( event?: EventWithOptionalKeyState, ): number | undefined { if (event?.altKey) return smallStep(); if (event?.shiftKey) return largeStep(); return step(); } function getAllowedNonNumericKeys(): Set { const { decimal, group, currency, literal } = getNumberLocaleDetails( locale(), format(), ); const keys = new Set(); for (const symbol of BASE_NON_NUMERIC_SYMBOLS) keys.add(symbol); if (decimal) keys.add(decimal); if (group) { keys.add(group); if (SPACE_SEPARATOR_RE.test(group)) keys.add(" "); } const style = format()?.style; const fmtUnit = format()?.unit; const allowPercentSymbols = style === "percent" || (style === "unit" && fmtUnit === "percent"); const allowPermilleSymbols = style === "percent" || (style === "unit" && fmtUnit === "permille"); if (allowPercentSymbols) for (const k of PERCENTAGES) keys.add(k); if (allowPermilleSymbols) for (const k of PERMILLE) keys.add(k); if (style === "currency" && currency) keys.add(currency); if (literal) { for (const ch of Array.from(literal)) keys.add(ch); if (SPACE_SEPARATOR_RE.test(literal)) keys.add(" "); } for (const k of PLUS_SIGNS_WITH_ASCII) keys.add(k); if (minWithDefault() < 0) { for (const k of MINUS_SIGNS_WITH_ASCII) keys.add(k); } return keys; } function setValue( raw: number | null, details: NumberFieldRootChangeEventDetails, ): boolean { const eventWithKeys = details.event as | EventWithOptionalKeyState | undefined; const dir = (details as { direction?: -1 | 1 }).direction; const reason = details.reason; const shouldClamp = !allowOutOfRange() || !( reason === REASONS.inputChange || reason === REASONS.inputBlur || reason === REASONS.inputPaste || reason === REASONS.inputClear || reason === REASONS.none ); const validatedValue = toValidatedNumber(raw, { step: dir ? (getStepAmount(eventWithKeys) ?? DEFAULT_STEP) * dir : undefined, format: formatOptionsRef.current, minWithDefault: minWithDefault(), maxWithDefault: maxWithDefault(), minWithZeroDefault: minWithZeroDefault(), snapOnStep: snapOnStep(), small: eventWithKeys?.altKey ?? false, clamp: shouldClamp, }); const isInputReason = reason === REASONS.inputChange || reason === REASONS.inputClear || reason === REASONS.inputBlur || reason === REASONS.inputPaste || reason === REASONS.none; const currentValue = value(); const shouldFireChange = validatedValue !== currentValue || isInputReason; if (shouldFireChange) { // Pure event-emitter — runWithOwner(null) escapes the reactive owner // so consumer signal writes don't trip SIGNAL_WRITE_IN_OWNED_SCOPE. runWithOwner(null, () => options.onValueChange?.(validatedValue, details), ); if (details.isCanceled) { return shouldFireChange; } // Respect the wrapper's IME-composition guard; defaults to true. const shouldSync = readOption(options.shouldSyncInput) ?? true; if (shouldSync) { setInputValueState(formatNumber(validatedValue, locale(), format())); } } return shouldFireChange; } function increment( amount: number, { direction, currentValue, event, reason }: IncrementValueParameters, ): boolean { const prevValue = currentValue == null ? untrack(value) : currentValue; const nextValue = typeof prevValue === "number" ? prevValue + amount * direction : Math.max(0, untrack(min) ?? 0); return setValue( nextValue, createChangeEventDetails(reason, event as never, undefined, { direction, }), ); } function commitValue( next: number | null, details: NumberFieldRootCommitEventDetails, ): void { runWithOwner(null, () => options.onValueCommitted?.(next, details)); } function setInputValue(next: string): void { setInputValueState(next); } const api: NumberFieldApi = { value, inputValue, minWithDefault, maxWithDefault, min, max, step, smallStep, largeStep, inputMode, locale, setValue, increment, commitValue, getStepAmount, getAllowedNonNumericKeys, setInputValue, Provider: (props) => ( {props.children} ), }; (api as NumberFieldApi & Snapshottable)[SNAPSHOT_KEYS] = NUMBER_FIELD_SNAPSHOT_KEYS; return api; } const NUMBER_FIELD_SNAPSHOT_KEYS = snapshotKeys( "value", "inputValue", "min", "max", "step", "smallStep", "largeStep", "inputMode", );