import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { omit, untrack } from "solid-js"; import { createChangeEventDetails, createGenericEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useFieldRootContext } from "../../internals/field-context"; import { useFormContext } from "../../internals/form-context"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; 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 type { NumberFieldRootState } from "../root/number-field-root.tsx"; import { useNumberFieldRootContext } from "../root/number-field-root-context.ts"; import { DEFAULT_STEP } from "../utils/constants.ts"; import { formatNumber, formatNumberMaxPrecision, } from "../utils/format-number.ts"; import { ANY_MINUS_DETECT_RE, ANY_MINUS_RE, ANY_PLUS_DETECT_RE, ANY_PLUS_RE, ARABIC_DETECT_RE, FULLWIDTH_DETECT_RE, getNumberLocaleDetails, HAN_DETECT_RE, PERSIAN_DETECT_RE, parseNumber, } from "../utils/parse.ts"; import { numberFieldDataAttrs } from "../utils/state-attributes-mapping.ts"; const NAVIGATE_KEYS = new Set([ "Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "Enter", "Escape", ]); export interface NumberFieldInputState extends NumberFieldRootState {} export interface NumberFieldInputProps extends BaseUIComponentProps<"input", NumberFieldInputState> { /** Tag/component to render. Defaults to `"input"`. */ as?: ValidComponent; "aria-roledescription"?: string | undefined; } /** * The native input control of the number field. Renders an ``. * * Solid 2 adaptations (mirrors * `packages/react/src/number-field/input/NumberFieldInput.tsx`): * * - `onInput` maps to React's `onChange` (Solid's `onChange` fires on * commit, not every keystroke). IME composition handling is kept as-is * via the `event.which === 229` early return in `onKeyDown`. * - Field integration goes through `useFieldControlValidation` which wires * the visible input into ``'s registration + validation * pipeline. The blur handler calls `commitValidation` in `onBlur` mode, * matching the post-review slider-thumb pattern (see * `src/slider/thumb/slider-thumb.tsx:491-511`). * - `aria-invalid` is seeded with `"false"` via * `getInputValidationProps()` so the key stays in Solid 2's DOM-spread * `ownKeys` from render 1 (see * `test/internals/merge-props-undefined-attr.test.tsx`). */ export function NumberFieldInput(props: NumberFieldInputProps): JSX.Element { const context = useNumberFieldRootContext(); const field = useFieldRootContext(); const { clearErrors } = useFormContext(); const hasTouchedInputRef = { current: false }; const blockRevalidationRef = { current: false }; // Hook reads its accessor inputs once at body to seed registration — // `untrack` keeps the snapshot reads out of the component-body // strict-read shell. Reactivity flows through the retained closures. const { getInputValidationProps } = untrack(() => useFieldControlValidation({ value: () => context.value(), id: () => context.id, controlRef: context.inputRef, getValue: () => context.value(), }), ); useValueChanged( () => context.value(), (_next, previousValue) => { const validateOnChange = field.shouldValidateOnChange(); clearErrors(context.name()); if (validateOnChange) { void field.validation.commit(context.value()); } if (previousValue === context.value() || validateOnChange) { return; } if (blockRevalidationRef.current) { blockRevalidationRef.current = false; return; } void field.validation.commit(context.value(), true); }, ); function onFocus( event: FocusEvent & { currentTarget: HTMLInputElement }, ): void { if (event.defaultPrevented || context.readOnly() || context.disabled()) { return; } field.setFocused(true); if (hasTouchedInputRef.current) return; hasTouchedInputRef.current = true; const target = event.currentTarget; const length = target.value.length; target.setSelectionRange(length, length); } function onBlur( event: FocusEvent & { currentTarget: HTMLInputElement }, ): void { if (event.defaultPrevented || context.readOnly() || context.disabled()) { return; } field.setTouched(true); field.setFocused(false); const hadManualInput = !context.allowInputSyncRef.current; const hadPendingProgrammaticChange = context.hasPendingCommitRef.current; context.allowInputSyncRef.current = true; const inputText = context.inputValue(); if (inputText.trim() === "") { context.setValue( null, createChangeEventDetails(REASONS.inputClear, event), ); if (field.validationMode() === "onBlur") { void field.validation.commit(null); } context.onValueCommitted( null, createGenericEventDetails(REASONS.inputClear, event), ); return; } const formatOptions = context.formatOptionsRef.current; const parsedValue = parseNumber(inputText, context.locale(), formatOptions); if (parsedValue === null) { return; } const hasExplicitPrecision = formatOptions?.maximumFractionDigits != null || formatOptions?.minimumFractionDigits != null; const maxFrac = formatOptions?.maximumFractionDigits; const committed = hasExplicitPrecision && typeof maxFrac === "number" ? Number(parsedValue.toFixed(maxFrac)) : parsedValue; const nextEventDetails = createGenericEventDetails( REASONS.inputBlur, event, ); const currentValue = context.value(); const shouldUpdateValue = currentValue !== committed; const shouldCommit = hadManualInput || shouldUpdateValue || hadPendingProgrammaticChange; if (field.validationMode() === "onBlur") { void field.validation.commit(committed); } if (shouldUpdateValue) { blockRevalidationRef.current = true; context.setValue( committed, createChangeEventDetails(REASONS.inputBlur, event), ); } if (shouldCommit) { context.onValueCommitted(committed, nextEventDetails); } const canonicalText = formatNumber( committed, context.locale(), formatOptions, ); const shouldPreserveFullPrecision = !hasExplicitPrecision && parsedValue === currentValue && inputText === formatNumberMaxPrecision(parsedValue, context.locale(), formatOptions); if (!shouldPreserveFullPrecision && inputText !== canonicalText) { context.setInputValue(canonicalText); } } function onInput( event: InputEvent & { currentTarget: HTMLInputElement }, ): void { if (event.defaultPrevented) return; context.allowInputSyncRef.current = false; const targetValue = event.currentTarget.value; if (targetValue.trim() === "") { context.setInputValue(targetValue); context.setValue( null, createChangeEventDetails(REASONS.inputClear, event), ); return; } const allowedNonNumericKeys = context.getAllowedNonNumericKeys(); const isValidCharacterString = Array.from(targetValue).every((ch) => { const isAsciiDigit = ch >= "0" && ch <= "9"; const isArabicNumeral = ARABIC_DETECT_RE.test(ch); const isHanNumeral = HAN_DETECT_RE.test(ch); const isPersianNumeral = PERSIAN_DETECT_RE.test(ch); const isFullwidthNumeral = FULLWIDTH_DETECT_RE.test(ch); const isMinus = ANY_MINUS_DETECT_RE.test(ch); return ( isAsciiDigit || isArabicNumeral || isHanNumeral || isPersianNumeral || isFullwidthNumeral || isMinus || allowedNonNumericKeys.has(ch) ); }); if (!isValidCharacterString) return; const parsedValue = parseNumber( targetValue, context.locale(), context.formatOptionsRef.current, ); context.setInputValue(targetValue); if (parsedValue !== null) { context.setValue( parsedValue, createChangeEventDetails(REASONS.inputChange, event), ); } } function onKeyDown( event: KeyboardEvent & { currentTarget: HTMLInputElement }, ): void { if (event.defaultPrevented || context.readOnly() || context.disabled()) { return; } context.allowInputSyncRef.current = true; const allowedNonNumericKeys = context.getAllowedNonNumericKeys(); let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key); const { decimal, currency, percentSign } = getNumberLocaleDetails( context.locale(), context.formatOptionsRef.current, ); const selectionStart = event.currentTarget.selectionStart; const selectionEnd = event.currentTarget.selectionEnd; const inputText = context.inputValue(); const isAllSelected = selectionStart === 0 && selectionEnd === inputText.length; const selectionContainsIndex = (index: number): boolean => selectionStart != null && selectionEnd != null && index >= selectionStart && index < selectionEnd; if ( ANY_MINUS_DETECT_RE.test(event.key) && Array.from(allowedNonNumericKeys).some((k) => ANY_MINUS_DETECT_RE.test(k || ""), ) ) { const existingIndex = inputText.search(ANY_MINUS_RE); const isReplacingExisting = existingIndex != null && existingIndex !== -1 && selectionContainsIndex(existingIndex); isAllowedNonNumericKey = !( ANY_MINUS_DETECT_RE.test(inputText) || ANY_PLUS_DETECT_RE.test(inputText) ) || isAllSelected || isReplacingExisting; } if ( ANY_PLUS_DETECT_RE.test(event.key) && Array.from(allowedNonNumericKeys).some((k) => ANY_PLUS_DETECT_RE.test(k || ""), ) ) { const existingIndex = inputText.search(ANY_PLUS_RE); const isReplacingExisting = existingIndex != null && existingIndex !== -1 && selectionContainsIndex(existingIndex); isAllowedNonNumericKey = !( ANY_MINUS_DETECT_RE.test(inputText) || ANY_PLUS_DETECT_RE.test(inputText) ) || isAllSelected || isReplacingExisting; } for (const symbol of [decimal, currency, percentSign]) { if (event.key === symbol && symbol) { const symbolIndex = inputText.indexOf(symbol); const isSymbolHighlighted = selectionContainsIndex(symbolIndex); isAllowedNonNumericKey = !inputText.includes(symbol) || isAllSelected || isSymbolHighlighted; } } const isAsciiDigit = event.key >= "0" && event.key <= "9"; const isArabicNumeral = ARABIC_DETECT_RE.test(event.key); const isHanNumeral = HAN_DETECT_RE.test(event.key); const isFullwidthNumeral = FULLWIDTH_DETECT_RE.test(event.key); const isNavigateKey = NAVIGATE_KEYS.has(event.key); // IME composition (`event.which === 229`) takes the early-return path // so we don't eat the pinyin/Japanese/etc. stroke that the IME will // later commit through a regular `input` event. if ( (event as KeyboardEvent & { which?: number }).which === 229 || event.altKey || event.ctrlKey || event.metaKey || isAllowedNonNumericKey || isAsciiDigit || isArabicNumeral || isFullwidthNumeral || isHanNumeral || isNavigateKey ) { return; } const parsedValue = parseNumber( inputText, context.locale(), context.formatOptionsRef.current, ); const amount = context.getStepAmount(event) ?? DEFAULT_STEP; // Prevent insertion of text or caret from moving. event.preventDefault(); event.stopPropagation(); const commitDetails = createGenericEventDetails(REASONS.keyboard, event); if (event.key === "ArrowUp") { context.incrementValue(amount, { direction: 1, currentValue: parsedValue, event, reason: REASONS.keyboard, }); context.onValueCommitted( context.lastChangedValueRef.current ?? context.valueRef.current, commitDetails, ); } else if (event.key === "ArrowDown") { context.incrementValue(amount, { direction: -1, currentValue: parsedValue, event, reason: REASONS.keyboard, }); context.onValueCommitted( context.lastChangedValueRef.current ?? context.valueRef.current, commitDetails, ); } else if (event.key === "Home" && context.min() != null) { context.setValue( context.min() as number, createChangeEventDetails(REASONS.keyboard, event), ); context.onValueCommitted( context.lastChangedValueRef.current ?? context.valueRef.current, commitDetails, ); } else if (event.key === "End" && context.max() != null) { context.setValue( context.max() as number, createChangeEventDetails(REASONS.keyboard, event), ); context.onValueCommitted( context.lastChangedValueRef.current ?? context.valueRef.current, commitDetails, ); } } function onPaste(event: ClipboardEvent): void { if (event.defaultPrevented || context.readOnly() || context.disabled()) { return; } event.preventDefault(); const clipboardData = event.clipboardData; if (!clipboardData) return; const pastedData = clipboardData.getData("text/plain"); const parsedValue = parseNumber( pastedData, context.locale(), context.formatOptionsRef.current, ); if (parsedValue !== null) { context.allowInputSyncRef.current = false; context.setValue( parsedValue, createChangeEventDetails(REASONS.inputPaste, event), ); context.setInputValue(pastedData); } } const userStyling = resolveClassStyle(props, () => context.state()); const userProps = omit( props as Record, "class", "style", "attrs", "as", ); const localRef = (el: HTMLInputElement | null): void => { context.inputRef.current = el; }; const internalSlot = { id: context.id, get required() { return context.required(); }, get disabled() { return context.disabled(); }, get readOnly() { return context.readOnly(); }, get inputMode() { return context.inputMode(); }, get value() { return context.inputValue(); }, type: "text" as const, autocomplete: "off" as const, autocorrect: "off" as const, spellcheck: false, "aria-roledescription": props["aria-roledescription"] ?? "Number field", onFocus, onBlur, onInput, onKeyDown, onPaste, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, numberFieldDataAttrs(context.state), ); merged = mergeRootProps( merged, getInputValidationProps() as Record, ); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "input"); return ; } export namespace NumberFieldInput { export type State = NumberFieldInputState; export type Props = NumberFieldInputProps; }