// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file NumberInput.tsx * @input Uses React, useId, useState, useMemo, useCallback, Field, Icon, InputGroupContext * @output Exports NumberInput component, NumberInputProps * @position Core implementation; consumed by index.ts, tested by NumberInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/NumberInput/numberParser.ts (locale-aware parsing) * - /packages/core/src/NumberInput/numberInputCommit.ts (draft commit policy) * - /packages/core/src/NumberInput/NumberInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/NumberInput/NumberInput.test.tsx (tests for new/changed behavior) * - /packages/core/src/NumberInput/index.ts (exports if types change) * - /apps/storybook/stories/NumberInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/NumberInput/ (showcase blocks) */ import { useId, useState, useMemo, useCallback, useRef, type FocusEvent, type KeyboardEvent, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, spacingVars, typographyVars, typeScaleVars, borderVars } from "../theme/tokens.stylex.js"; import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles, type FieldStatusVariant } from "../Field/index.js"; import { Icon, renderIconSlot, type IconType } from "../Icon/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useTooltip } from "../Tooltip/index.js"; import { getInputARIA } from "../utils/index.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { useInputContainer } from "../hooks/useInputContainer.js"; import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useInputGroup } from "../InputGroup/InputGroupContext.js"; // Public padding tokens for the `number-input` theme target. A theme writes an // ordinary `padding` (in ANY spelling — the shorthand, `paddingBlock`, or a // single `paddingBlockStart`) and the pipeline's `container` expansion parses // it and emits these normalized per-side tokens; the wrapper and the stepper // column both read them, so the column tracks whatever the theme sets instead // of assuming the default. Routing through the shared expansion rather than a // hand-rolled property→var mapping is what makes every spelling work: a // mapping only fires for the exact property name it names. // // Read order per level: `var(--astryx-…, )`, terminating at the // shared field defaults (NOT the container default --spacing-4, which is a // layout metric and would resize every themed field). Built as chained const // strings — no function calls — so StyleX can statically analyze them; same // shape as the card/section/dialog chains in Layout/container.stylex.ts. const FIELD_PAD_BLOCK = spacingVars['--spacing-1']; const FIELD_PAD_INLINE = spacingVars['--spacing-2']; const padBlockAll = `var(--astryx-number-input-padding, ${FIELD_PAD_BLOCK})`; const padInlineAll = `var(--astryx-number-input-padding, ${FIELD_PAD_INLINE})`; const padInline = `var(--astryx-number-input-padding-inline, ${padInlineAll})`; const padInlineStart = `var(--astryx-number-input-padding-inline-start, ${padInline})`; const padInlineEnd = `var(--astryx-number-input-padding-inline-end, ${padInline})`; const padBlockStart = `var(--astryx-number-input-padding-block-start, ${padBlockAll})`; const padBlockEnd = `var(--astryx-number-input-padding-block-end, ${padBlockAll})`; const styles = { wrapper: { kY2c9j: "x1vjfegm", kLKAdn: "x4kswst", kGO01o: "x3jnfsm", kZCmMZ: "x1jni19p", kwRFfy: "x17sfb9", $$css: true as const }, wrapperWithNumberSteppers: { kwRFfy: "xyri2b", $$css: true as const }, numberStepperButton: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kUk6DE: "x98rzlu", kAzted: "x2lwn1j", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kMwMTN: "xv9yike", kWkggS: "x10xzikg", kkrTdU: "x1ypdohk x16khyan", kI3sdo: "x1a2a7pz", $$css: true as const }, numberStepperButtonDisabled: { kMwMTN: "xqa6c3m", kkrTdU: "xt0e3qv", kKwaWg: "x18o3ruo", $$css: true as const }, decrementButton: { kEafiO: "x11xkdxz", kPef9Z: "x13fuv20", kLZC3w: "x1mly77v", $$css: true as const }, incrementIcon: { k3aq6I: "x19jd1h0", $$css: true as const } }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; export type NumberInputSize = keyof typeof sizeStyles; import { groupStyles } from "../InputGroup/groupStyles.js"; // Re-export shared types for convenience export type { InputStatus as NumberInputStatus, InputStatusType as NumberInputStatusType } from "../Field/index.js"; import { isImeKeyEvent, mergeProps, mergeRefs } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator, useLocale } from "../i18n/index.js"; import { formatEditableNumber } from "./numberParser.js"; import { parseNumberInput, resolveNumberInputCommit } from "./numberInputCommit.js"; interface NumberInputPropsBase extends Omit { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Label text for the input (always rendered for accessibility). */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Description text displayed between the label and input. */ description?: string; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the field is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * Whether the input is disabled. * @default false */ isDisabled?: boolean; /** * Whether the input is read-only. * The value is shown at full opacity and still submits with the form, but * cannot be edited. Unlike `isDisabled`, a read-only input is not dimmed and * stays in the tab order — use it for a value the user should see and send * but not change. Stepping is off in every form while read-only: arrow keys, * the wheel, and the number steppers. `isDisabled` takes precedence when both * are set. * @default false */ isReadOnly?: boolean; /** * Explains why the input is disabled. When set together with `isDisabled`, * the input shows a tooltip with this text on hover and keyboard focus, and * stays focusable (via `aria-disabled`) so the reason is discoverable by * keyboard and assistive technology. The field cannot be edited (it becomes * read-only) while disabled. * * Use this instead of wrapping a disabled input in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * Icon to display at the start of the input. * Accepts a ReactNode (e.g. ``) or an SVG icon component directly. */ startIcon?: ReactNode | IconType; /** * Icon to display before the label text. */ labelIcon?: ReactNode | IconType; /** * Status indicator for the input. * When set, displays a colored border and status icon. * If message is provided, displays a floating message box below the input. */ status?: InputStatus; /** * How the status message is placed relative to the input. * - 'attached': message overlaps directly below the input (bordered treatment) * - 'detached': message floats below as a separate element with spacing * - 'tooltip': no message box; the status icon becomes a focusable info-tip button that reveals the message on hover, keyboard focus, or tap * @default 'attached' */ statusVariant?: FieldStatusVariant; /** * The size of the input. * - 'sm': Compact size (28px height) * - 'md': Default size (32px height) * - 'lg': Large size (36px height) * @default 'md' */ size?: NumberInputSize; // onChange and hasClear defined in discriminated union below /** * The current value of the input. * Use null or undefined to represent an empty/unset value. */ value: number | null | undefined; /** * Placeholder text shown when the input is empty. */ placeholder?: string; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; /** * Whether to automatically focus the input on mount. * @default false */ hasAutoFocus?: boolean; /** * The HTML name attribute for the input. * Useful for form submissions. */ htmlName?: string; /** * The HTML autocomplete attribute for the input. */ autoComplete?: string; /** * The minimum value allowed. * A smaller entry commits at this value on blur or Enter. */ min?: number | null; /** * The maximum value allowed. * A larger entry commits at this value on blur or Enter. */ max?: number | null; /** * The step increment for the input. * @default 1 */ step?: number | null; /** * Formats the committed value while the input is not being edited. * The raw numeric value is shown on focus so it remains editable. */ formatValue?: (value: number) => string; /** * Whether scrolling the wheel over a focused input steps the value. * @default true */ isWheelEnabled?: boolean; /** * Whether to show increment and decrement buttons at the end of the input. * @default false */ hasNumberSteppers?: boolean; /** * Units text to display at the end of the input (e.g., "%" or "GB"). */ units?: string | null; /** * Whether to only allow integer values (no floating point). * @default false */ isIntegerOnly?: boolean; /** * Callback fired when the input receives focus. */ onFocus?: (e: FocusEvent) => void; /** * Callback fired when the input loses focus. */ onBlur?: (e: FocusEvent) => void; /** * Callback fired when the user presses the Enter key. */ onEnter?: () => void; /** * Callback fired on keydown events on the input. */ onKeyDown?: (e: KeyboardEvent) => void; } /** * Without `hasClear`, onChange only receives valid numbers. * With `hasClear`, onChange also receives `null` when the user clears the input. */ type NumberInputPropsNonClearable = NumberInputPropsBase & { hasClear?: false; onChange: (value: number) => void; }; type NumberInputPropsClearable = NumberInputPropsBase & { /** * Whether to show a clear button when a value is set. * When clicked, resets the value to null and returns focus to the input. * * When enabled, the `onChange` callback type widens to also accept `null`, * signaling that the user cleared the input. */ hasClear: true; onChange: (value: number | null) => void; }; export type NumberInputProps = NumberInputPropsNonClearable | NumberInputPropsClearable; type StepDirection = -1 | 1; function getDecimalPlaces(value: number): number { const [coefficient, exponentText] = String(value).toLowerCase().split('e'); const fractionLength = coefficient.split('.')[1]?.length ?? 0; const exponent = exponentText == null ? 0 : Number(exponentText); return Math.max(0, fractionLength - exponent); } function getEffectiveStep(step: number | null | undefined, isIntegerOnly: boolean): number { if (step == null || !Number.isFinite(step) || step <= 0 || isIntegerOnly && !Number.isInteger(step)) { return 1; } return step; } function getSteppedValue({ currentValue, direction, min, max, step, isIntegerOnly }: { currentValue: number | null; direction: StepDirection; min?: number | null; max?: number | null; step?: number | null; isIntegerOnly: boolean; }): number | null { const effectiveStep = getEffectiveStep(step, isIntegerOnly); const stepBase = min != null && (!isIntegerOnly || Number.isInteger(min)) ? min : 0; let nextValue: number; if (currentValue == null) { nextValue = direction === 1 ? min ?? 0 : max ?? 0; if (isIntegerOnly) { nextValue = direction === 1 ? Math.ceil(nextValue) : Math.floor(nextValue); } } else { const stepPosition = (currentValue - stepBase) / effectiveStep; const tolerance = Number.EPSILON * Math.max(1, Math.abs(stepPosition)) * 4; const nextStepPosition = direction === 1 ? Math.floor(stepPosition + tolerance) + 1 : Math.ceil(stepPosition - tolerance) - 1; nextValue = stepBase + nextStepPosition * effectiveStep; } const precision = Math.min(12, Math.max(getDecimalPlaces(effectiveStep), getDecimalPlaces(stepBase))); nextValue = Number(nextValue.toFixed(precision)); // Clamp after rounding so a bound with finer precision than the step cannot // be rounded back out of its own range. if (min != null) { nextValue = Math.max(min, nextValue); } if (max != null) { nextValue = Math.min(max, nextValue); } if (!Number.isFinite(nextValue)) { return currentValue; } if (isIntegerOnly && !Number.isInteger(nextValue)) { return currentValue; } return Object.is(nextValue, -0) ? 0 : nextValue; } /** * A number input component for collecting numeric user input. * Commits text edits on blur or Enter and only calls onChange when the whole * draft passes validation. * * @example * ``` * * * ``` */ export function NumberInput({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, isReadOnly = false, disabledMessage, startIcon, labelIcon, status, statusVariant = 'attached', size: sizeProp, onChange, value, placeholder, labelTooltip, hasAutoFocus = false, htmlName, autoComplete, min, max, step, formatValue, isWheelEnabled = true, hasNumberSteppers = false, units, isIntegerOnly = false, onFocus, onBlur, hasClear, onEnter, onKeyDown, width, xstyle, className, style, ref, ...rest }: NumberInputProps) { const t = useTranslator(); const locale = useLocale(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const size = useSize(sizeProp, 'md'); const id = useId(); const inputLabelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const unitsID = useId(); const inputRef = useRef(null); const containerRef = useRef(null); const inputGroup = useInputGroup(); // Pending input while user is typing (null = show formatted value) const [pendingInput, setPendingInput] = useState(null); const [isFocused, setIsFocused] = useState(false); // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the input container (which already exists) and // the input stays perceivable via aria-disabled instead of the native // disabled attribute. The field is made read-only so it can't be typed into, // and value mutation is blocked by the isDisabled guard in the handlers. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container div is not naturally focusable; focusin bubbles up from // the input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant, isInGroup: !!inputGroup }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelID, [description ? descriptionID : null, // The status message element is rendered by Field, which is skipped // inside an InputGroup — only reference it when it actually exists. !inputGroup && statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, // The tooltip variant renders no message box; describe the input by the // tooltip's content instead so the status is still announced. statusTooltipDescribedBy, units ? unitsID : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); const parseInput = useCallback((text: string) => parseNumberInput(text, { min, max, isIntegerOnly, locale }), [isIntegerOnly, locale, max, min]); const formattedValue = useMemo(() => { if (value == null) { return ''; } return formatValue?.(value) ?? String(value); }, [formatValue, value]); // Preserve pending text while editing. Otherwise show the formatted value // at rest and the raw numeric value while focused so it remains editable. const displayValue = useMemo(() => { if (pendingInput !== null) { return pendingInput; } if (value == null) { return ''; } return isFocused ? formatEditableNumber(value, locale) : formattedValue; }, [formattedValue, isFocused, locale, pendingInput, value]); // Check if current pending input is valid (for styling purposes) const isInputValid = useMemo(() => { if (pendingInput === null || !pendingInput.trim()) { return true; } return parseInput(pendingInput) !== null; }, [pendingInput, parseInput]); // Keep the whole text edit as a draft until an explicit commit boundary. const handleInputChange = useCallback((e: React.ChangeEvent) => { // Value can't change while showing a disabled message (the field is // read-only and non-native-disabled), but guard the handler too so the // pending value never changes. if (isDisabled || isReadOnly) { return; } setPendingInput(e.target.value); }, [isDisabled, isReadOnly]); // Handle focus const handleFocus = useCallback((e: FocusEvent) => { setIsFocused(true); onFocus?.(e); }, [onFocus]); const commitPendingInput = useCallback((trigger: 'blur' | 'Enter') => { if (pendingInput === null) { return; } const decision = resolveNumberInputCommit(pendingInput, { min, max, isIntegerOnly, locale, hasClear: !!hasClear }); if (trigger === 'blur' || decision.type === 'commit' && decision.didClamp) { setPendingInput(null); } if (decision.type === 'clear') { if (hasClear && value != null) { onChange(null); } } else if (decision.type === 'commit' && decision.value !== value) { onChange(decision.value); } }, [hasClear, isIntegerOnly, locale, max, min, onChange, pendingInput, value]); // Blur ends the edit and displays the resulting committed value. const handleBlur = useCallback((e: FocusEvent) => { commitPendingInput('blur'); setIsFocused(false); onBlur?.(e); }, [commitPendingInput, onBlur]); const valueForStepping = useMemo(() => { if (pendingInput === null) { return value ?? null; } if (pendingInput.trim() === '') { return null; } return parseInput(pendingInput) ?? value ?? null; }, [parseInput, pendingInput, value]); const getNextValue = useCallback((direction: StepDirection) => getSteppedValue({ currentValue: valueForStepping, direction, min, max, step, isIntegerOnly }), [isIntegerOnly, max, min, step, valueForStepping]); const stepValue = useCallback((direction: StepDirection) => { // A read-only field is not steppable by any route: keyboard, wheel, or // the stepper buttons all land here. if (isDisabled || isReadOnly) { return; } const nextValue = getNextValue(direction); if (nextValue == null) { return; } setPendingInput(null); if (nextValue !== value) { onChange(nextValue); } }, [getNextValue, isDisabled, isReadOnly, onChange, value]); // Handle keyboard events const handleKeyDown = useCallback((e: KeyboardEvent) => { // The field is type="text" for formatted display, so an IME can compose // into it: Enter commits the candidate and the arrows walk the candidate // window. The composing keydown fires before compositionend, so without // this guard those keystrokes would commit or step the value instead. // See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } const hasModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; if (!hasModifier && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { onKeyDown?.(e); if (e.defaultPrevented) { return; } e.preventDefault(); stepValue(e.key === 'ArrowUp' ? 1 : -1); return; } if (e.key === 'Enter') { commitPendingInput('Enter'); onEnter?.(); } onKeyDown?.(e); }, [commitPendingInput, onEnter, onKeyDown, stepValue]); // React's delegated wheel listener can be passive, so use a native, // explicitly non-passive listener to prevent page scrolling only when this // focused input is intentionally consuming the gesture to step its value. const wheelListenerRef = useCallback((input: HTMLInputElement | null) => { if (input == null || !isWheelEnabled) { return; } const handleWheel = (event: WheelEvent) => { // Bail before preventDefault so a read-only input never swallows the // page scroll it cannot act on. if (document.activeElement !== input || isDisabled || isReadOnly || event.deltaY === 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } event.preventDefault(); event.stopPropagation(); stepValue(event.deltaY < 0 ? 1 : -1); }; input.addEventListener('wheel', handleWheel, { passive: false }); return () => input.removeEventListener('wheel', handleWheel); }, [isDisabled, isReadOnly, isWheelEnabled, stepValue]); const mergedInputRef = useMemo(() => mergeRefs(ref, inputRef, wheelListenerRef), [ref, wheelListenerRef]); const canIncrement = getNextValue(1) !== valueForStepping; const canDecrement = getNextValue(-1) !== valueForStepping; // Handle clear button click const handleClear = useCallback((e?: React.MouseEvent) => { if (hasClear) { onChange(null); } setPendingInput(null); if (!e || e.detail === 0) { inputRef.current?.focus(); } else { // Defer focus restoration past the button's unmount task so iOS Safari // and touch browsers don't jump the page scroll to 0 on tap. requestAnimationFrame(() => { inputRef.current?.focus({ preventScroll: true }); }); } }, [hasClear, onChange]); // Focus input when clicking anywhere on the wrapper (icons, padding, etc.) const { onClick: handleWrapperClick, onMouseUp: handleWrapperMouseUp } = useInputContainer({ containerRef, inputRef, disabled: isDisabled }); const inputWrapper =
{ containerRef.current = el; // Anchor + hover/focus listeners for the disabled-message tooltip. // Handlers are gated internally by isEnabled, and anchor names // compose, so attaching unconditionally is safe. disabledMessageTooltip.ref(el); }} onClick={handleWrapperClick} onMouseUp={handleWrapperMouseUp} {...mergeProps(themeProps('number-input', { size, status: status?.type ?? null, disabled: isDisabled ? 'disabled' : null, readonly: isReadOnly ? 'readonly' : null }), stylex.props(inputWrapperStyles.base, styles.wrapper, hasNumberSteppers && styles.wrapperWithNumberSteppers, sizeStyles[size], isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}> {startIcon && renderIconSlot(startIcon, { size: 'sm', color: 'secondary' })} {inputGroup && {label}} {formatValue && htmlName && !isDisabled && } {units && {units} } {/* Live region announcing invalid typed input to assistive technology. The value silently reverts on blur, so without this a screen-reader user would get no feedback that their entry was rejected (WCAG 3.3.1). */} {!isInputValid ? 'Invalid number' : ''} {hasClear && value != null && !isDisabled && !isReadOnly && } {statusIcon} {hasNumberSteppers &&
}
; if (inputGroup) { return <> {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } return {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } NumberInput.displayName = 'NumberInput';