diff --git a/packages/components/rollup.config.mjs b/packages/components/rollup.config.mjs
index 772aa316f5..c6c6d0b1c9 100644
--- a/packages/components/rollup.config.mjs
+++ b/packages/components/rollup.config.mjs
@@ -15,14 +15,25 @@ const createMultiInput =
typeof multiInput === "function" ? multiInput : multiInput.default;
/**
- * When PREBUILD_CSS is supplied, only build the main index.ts file.
+ * When PREBUILD_CSS is supplied, only build the main barrels.
* This ensures postcss maintains consistent ordering of styles across builds.
*
* Using multiInput (input with globs) produces inconsistent ordering within styles.css
* because files are loaded in a non-deterministic order, and postcss bundles them in
* that order.
+ *
+ * The primitives barrel is included in addition to the main barrel so that styled
+ * primitives (under `src/primitives/`) contribute their CSS module hashes to the
+ * extracted styles.css. Without this, primitives' class hashes appear in the JS
+ * bundle but not in the stylesheet, so consumers see no styles applied.
*/
const PREBUILD_CSS = process.env.PREBUILD_CSS === "true";
+// Object form gives each entry a unique `[name]` for output filenames; both
+// inputs share the basename `index.ts`, so the array form would collide.
+const PREBUILD_CSS_INPUTS = {
+ index: "src/index.ts",
+ "primitives/index": "src/primitives/index.ts",
+};
/**
* PostCSS plugin to remove @charset declarations.
@@ -52,7 +63,7 @@ export default {
warn(warning);
},
- input: PREBUILD_CSS ? "src/index.ts" : `src/**/index.{ts,tsx}`,
+ input: PREBUILD_CSS ? PREBUILD_CSS_INPUTS : `src/**/index.{ts,tsx}`,
plugins: [
nodePolyfills(),
nodeResolve(),
diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css
new file mode 100644
index 0000000000..d4f99ba1f9
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css
@@ -0,0 +1,376 @@
+.container {
+ width: 100%;
+}
+
+.inline {
+ display: inline-block;
+ width: auto;
+}
+
+.wrapper {
+ --field--placeholder-color: var(--color-base-blue--600);
+ --field--value-color: var(--color-heading);
+ --field--border-color: var(--color-border--interactive);
+
+ --field--placeholder-offset: 50%;
+ --field--placeholder-transform: -50%;
+
+ --field--textAlign: left;
+
+ --field--height: var(--space-largest);
+
+ --field--padding-top: calc(var(--space-base) - var(--space-smallest));
+ --field--padding-bottom: calc(var(--space-base) - var(--space-smallest));
+ --field--padding-left: var(--space-base);
+ --field--padding-right: var(--space-base);
+
+ --field--value-lineHeight: calc(var(--base-unit) * 1.25);
+
+ --field--background-color: var(--color-surface);
+
+ --field--base-elevation: var(--elevation-base);
+ --field--label-elevation: calc(var(--field--base-elevation) + 1);
+
+ --stepper--width: calc(var(--space-base) + var(--space-smaller));
+
+ display: flex;
+ position: relative;
+ flex-direction: row;
+ align-items: stretch;
+ width: 100%;
+ border: var(--border-base) solid var(--field--border-color);
+ border-radius: var(--radius-base);
+ /* Read radius vars via fallback so InputGroup's parent-level overrides
+ * still flatten inner corners; declaring them on `.wrapper` would
+ * shadow the inherited value. */
+ border-top-right-radius: var(
+ --public-field--top-right-radius,
+ var(--radius-base)
+ );
+ border-bottom-right-radius: var(
+ --public-field--bottom-right-radius,
+ var(--radius-base)
+ );
+ border-bottom-left-radius: var(
+ --public-field--bottom-left-radius,
+ var(--radius-base)
+ );
+ border-top-left-radius: var(
+ --public-field--top-left-radius,
+ var(--radius-base)
+ );
+ color: var(--field--value-color);
+ font-size: var(--typography--fontSize-base);
+ background-color: var(--field--background-color);
+}
+
+.wrapper * {
+ box-sizing: border-box;
+}
+
+.inline .wrapper {
+ width: auto;
+}
+
+.wrapper:focus-within {
+ z-index: var(--field--base-elevation);
+ box-shadow: var(--shadow-focus);
+}
+
+/**
+ * Base UI's `Field.Root` sets `data-invalid` on the outer container when the
+ * field's invalid state is true. We read that attribute instead of toggling
+ * a local `.invalid` class so Base UI is the single source of truth.
+ */
+.container[data-invalid] .wrapper,
+.container[data-invalid] .wrapper:focus-within {
+ --field--border-color: var(--color-critical);
+}
+
+.disabled {
+ --field--placeholder-color: var(--color-disabled);
+ --field--value-color: var(--color-disabled);
+ --field--background-color: var(--color-disabled--secondary);
+ --field--border-color: var(--color-border);
+}
+
+.disabled :disabled {
+ -webkit-text-fill-color: var(--field--value-color);
+ opacity: 1;
+}
+
+.small {
+ --field--padding-left: calc(var(--space-base) - var(--space-smaller));
+ --field--padding-right: calc(var(--space-base) - var(--space-smaller));
+ --field--padding-top: var(--space-small);
+ --field--padding-bottom: var(--space-small);
+ --field--height: calc(var(--space-larger) + var(--space-smaller));
+}
+
+.large {
+ --field--padding-left: var(--space-large);
+ --field--padding-right: var(--space-large);
+ --field--height: calc(var(--space-extravagant));
+}
+
+.center {
+ --field--textAlign: center;
+}
+
+.right {
+ --field--textAlign: right;
+}
+
+.inputWrapper {
+ display: flex;
+ position: relative;
+ z-index: var(--elevation-default);
+ height: var(--field--height);
+ flex: 1;
+}
+
+.input {
+ position: relative;
+ z-index: var(--field--base-elevation);
+ width: 100%;
+ padding-top: var(--field--padding-top);
+ padding-bottom: var(--field--padding-bottom);
+ padding-left: var(--field--padding-left);
+ padding-right: var(--field--padding-right);
+ border: none;
+ border-radius: var(--radius-base);
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: var(--field--value-lineHeight);
+ text-align: var(--field--textAlign);
+ background: transparent;
+ appearance: none;
+}
+
+.input:focus {
+ outline: none;
+}
+
+/**
+ * When the stepper is rendered as an in-flow flex sibling (default mode), it
+ * provides visual separation from the typed value. Tighten the input's
+ * own right padding so narrow consumers (~100px wrappers with a suffix) keep
+ * enough room for multi-digit values to display.
+ */
+.inputWrapper:has(> .stepper) .input {
+ padding-right: var(--space-smallest);
+}
+
+.label {
+ position: absolute;
+ top: var(--field--placeholder-offset);
+ left: 0;
+ z-index: var(--field--label-elevation);
+ width: 100%;
+ padding-left: var(--field--padding-left);
+ padding-right: var(--field--padding-right);
+ overflow: hidden;
+ color: var(--field--placeholder-color);
+ line-height: var(--field--value-lineHeight);
+ text-align: var(--field--textAlign);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+ transform: translateY(var(--field--placeholder-transform));
+}
+
+.inputWrapper:not(.hideLabel) .label {
+ transition: all var(--timing-quick);
+}
+
+/**
+ * Value-present mini-label state: when the input has a value (or focus),
+ * shrink and pin the placeholder label to the top-left and grow the input's
+ * vertical padding to make room.
+ *
+ * The variables are hoisted to `.wrapper` (not `.inputWrapper`) so that
+ * affix labels — which are flex siblings of `.inputWrapper` inside `.wrapper`
+ * in the default render — also inherit the new padding values and stay
+ * vertically aligned with the typed value.
+ */
+.wrapper:has(
+ .inputWrapper:not(.small, .hideLabel)
+ .input:not(:placeholder-shown)
+ + .label:not(:empty)
+ ) {
+ --field--placeholder-color: var(--color-text--secondary);
+ --field--placeholder-offset: var(--space-smallest);
+ --field--placeholder-transform: 0;
+ --field--padding-top: calc(var(--space-base) + var(--space-smaller));
+ --field--padding-bottom: var(--space-small);
+}
+
+.inputWrapper.disabled label {
+ --field--placeholder-color: var(--color-text-disabled);
+}
+
+.input:not(:placeholder-shown) + .label:not(:empty) {
+ font-size: var(--typography--fontSize-small);
+}
+
+.hideLabel .input:not(:placeholder-shown) + .label:not(:empty) {
+ visibility: hidden;
+}
+
+.inputWrapper.small .input:not(:placeholder-shown) + .label:not(:empty) {
+ display: none;
+}
+
+/**
+ * Affix labels render as flex siblings of `.inputWrapper` inside `.wrapper`.
+ * The .prefix and .suffix modifiers carry variant-specific spacing so the
+ * label sits visually flush against the typed value (mirroring V1 visuals).
+ */
+.affixLabel {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ padding-top: var(--field--padding-top);
+ padding-bottom: var(--field--padding-bottom);
+ font-size: var(--typography--fontSize-base);
+ line-height: var(--field--value-lineHeight);
+ white-space: nowrap;
+}
+
+/**
+ * Prefix label: negative right margin absorbs the input's left padding so the
+ * label sits flush against the typed value.
+ */
+.affixLabel.prefix {
+ margin: 0 calc((var(--field--padding-left) - var(--space-smallest)) * -1) 0 0;
+ padding-left: var(--field--padding-left);
+}
+
+/**
+ * Suffix label: no negative left margin (the stepper sits between the input
+ * value and this label, so we don't want to pull the label across it).
+ */
+.affixLabel.suffix {
+ padding-right: var(--field--padding-left);
+}
+
+/**
+ * Outer affix icon: rendered as a flex sibling of .inputWrapper inside the
+ * field group. The prefix variant uses a negative right margin to abut the
+ * input's left padding (V1 pattern); the suffix variant does NOT, because the
+ * stepper now sits between the input wrapper and the suffix icon, and a
+ * negative margin would pull the icon over the stepper.
+ */
+.affixIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 calc(var(--field--padding-left) * -1) 0 0;
+ padding: 0 var(--field--padding-right) 0 var(--field--padding-left);
+}
+
+.affixIcon.suffix {
+ margin: 0;
+ padding: 0 var(--field--padding-right) 0 var(--space-small);
+}
+
+/**
+ * Compound `<.Affix>`: a single flex sibling of .inputWrapper that bundles
+ * icon and/or label inline. Visually slightly less integrated than the default
+ * (split) layout but lets users compose freely.
+ */
+.compoundAffix {
+ display: flex;
+ align-items: center;
+ gap: var(--space-smaller);
+ padding: 0 var(--space-base);
+ color: var(--color-text--secondary);
+ font-size: var(--typography--fontSize-base);
+ white-space: nowrap;
+}
+
+.affixLabelText {
+ line-height: var(--field--value-lineHeight);
+}
+
+.stepper {
+ display: flex;
+ z-index: calc(var(--field--base-elevation) + 1);
+ width: var(--stepper--width);
+ opacity: 0;
+ transition: opacity var(--timing-quick);
+ flex-direction: column;
+ flex: 0 0 auto;
+}
+
+/* Default mode: stepper lives inside .inputWrapper. Reveal on local hover/focus. */
+.inputWrapper:hover .stepper,
+.inputWrapper:focus-within .stepper {
+ opacity: 1;
+}
+
+/**
+ * Compound mode: the consumer composes <.Stepper> as a direct child of
+ * NumberField.Group (.wrapper). Reveal on the wider group hover/focus there.
+ * The `>` direct-child selector ensures this only matches the compound case;
+ * default-mode steppers are nested deeper inside .inputWrapper.
+ */
+.wrapper:hover > .stepper,
+.wrapper:focus-within > .stepper {
+ opacity: 1;
+}
+
+.stepperButton {
+ display: flex;
+ padding: 0;
+ border: none;
+ color: var(--color-text--secondary);
+ font-size: 8px;
+ line-height: 1;
+ background: transparent;
+ cursor: pointer;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+}
+
+.stepperButton:hover {
+ color: var(--color-heading);
+ background: var(--color-surface--hover);
+}
+
+.stepperButton:focus-visible {
+ outline: var(--shadow-focus);
+ outline-offset: -2px;
+}
+
+.stepperButton:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+
+.belowField {
+ display: flex;
+ flex-direction: column;
+}
+
+/**
+ * `Field.Description` renders a `
`, which inherits the user-agent
+ * `margin-block` defaults. Reset margins so spacing comes only from our token.
+ */
+.description {
+ margin: var(--space-smaller) 0 0 0;
+ color: var(--color-text--secondary);
+ font-size: var(--typography--fontSize-small);
+}
+
+.fieldError {
+ align-items: center;
+ display: flex;
+ gap: var(--space-smaller);
+ padding: var(--space-smaller);
+ padding-left: 0;
+ color: var(--color-critical);
+ font-size: var(--typography--fontSize-small);
+}
diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts
new file mode 100644
index 0000000000..e98b4a1675
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts
@@ -0,0 +1,27 @@
+declare const styles: {
+ readonly "affixIcon": string;
+ readonly "affixLabel": string;
+ readonly "affixLabelText": string;
+ readonly "belowField": string;
+ readonly "center": string;
+ readonly "compoundAffix": string;
+ readonly "container": string;
+ readonly "description": string;
+ readonly "disabled": string;
+ readonly "fieldError": string;
+ readonly "hideLabel": string;
+ readonly "inline": string;
+ readonly "input": string;
+ readonly "inputWrapper": string;
+ readonly "label": string;
+ readonly "large": string;
+ readonly "prefix": string;
+ readonly "right": string;
+ readonly "small": string;
+ readonly "stepper": string;
+ readonly "stepperButton": string;
+ readonly "suffix": string;
+ readonly "wrapper": string;
+};
+export = styles;
+
diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx
new file mode 100644
index 0000000000..f0da6d40d8
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx
@@ -0,0 +1,342 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import React, { useState } from "react";
+import { InputNumberExperimental } from "@jobber/components/primitives";
+import { InputNumber } from "@jobber/components/InputNumber";
+import { Content } from "@jobber/components/Content";
+import { Heading } from "@jobber/components/Heading";
+
+const meta: Meta = {
+ title: "Primitives/InputNumberExperimental",
+ component: InputNumberExperimental,
+ args: {
+ placeholder: "Quantity",
+ min: 0,
+ max: 100,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ render: args => {
+ const [value, setValue] = useState(3);
+
+ return (
+
+ );
+ },
+};
+
+export const WithSuffixLabel: Story = {
+ render: args => {
+ const [value, setValue] = useState(0);
+
+ return (
+
+ );
+ },
+};
+
+export const WithPrefixLabelAndSuffixLabel: Story = {
+ render: args => {
+ const [value, setValue] = useState(42);
+
+ return (
+
+ );
+ },
+};
+
+/**
+ * V1 parity: prefix with both `label` and `icon`, suffix with `label`.
+ * Mirrors `InputNumberPrefixAndSuffixExample` from the V1 story.
+ */
+export const InvoiceTotal_V1Parity: Story = {
+ args: {
+ placeholder: "Invoice total",
+ },
+ render: args => {
+ const [value, setValue] = useState(100000);
+
+ return (
+
+ );
+ },
+};
+
+export const PrefixIconOnly: Story = {
+ args: { placeholder: "Search by amount" },
+ render: args => {
+ const [value, setValue] = useState(undefined);
+
+ return (
+
+ );
+ },
+};
+
+export const PrefixAndSuffixIcons: Story = {
+ args: { placeholder: "Pick a date offset" },
+ render: args => {
+ const [value, setValue] = useState(7);
+
+ return (
+
+ );
+ },
+};
+
+export const ClickableSuffixIcon: Story = {
+ args: { placeholder: "Repetitions" },
+ render: args => {
+ const [value, setValue] = useState(3);
+
+ return (
+ setValue(undefined),
+ }}
+ value={value}
+ />
+ );
+ },
+};
+
+export const WithDescriptionAndError: Story = {
+ render: args => {
+ const [value, setValue] = useState(150);
+
+ return (
+ 100 ? "Too high" : undefined}
+ onChange={setValue}
+ value={value}
+ />
+ );
+ },
+};
+
+/**
+ * All three sizes side-by-side. Useful for spotting padding/height regressions
+ * when changes are made to the `.small` or `.large` modifier rules.
+ */
+export const AllSizes: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => {
+ const [a, setA] = useState(42);
+ const [b, setB] = useState(42);
+ const [c, setC] = useState(42);
+
+ return (
+
+
+
+
+
+ );
+ },
+};
+
+export const Compound: Story = {
+ render: args => {
+ const [value, setValue] = useState(7);
+
+ return (
+
+
+
+
+
+ );
+ },
+};
+
+/**
+ * Side-by-side visual diff against V1's PrefixAndSuffix example for the
+ * full V1 affix surface (label-only, icon-only, label+icon, clickable suffix).
+ * Use this to spot any visual regressions vs the V1 baseline.
+ */
+export const V1ComparisonGrid: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => {
+ const [v1, setV1] = useState(100000);
+ const [v2, setV2] = useState(100000);
+ const [v3, setV3] = useState(undefined);
+ const [v4, setV4] = useState(undefined);
+ const [v5, setV5] = useState(7);
+ const [v6, setV6] = useState(7);
+ const [v7, setV7] = useState(3);
+ const [v8, setV8] = useState(3);
+
+ const cell = {
+ display: "grid" as const,
+ gridTemplateColumns: "1fr 1fr",
+ gap: "1rem",
+ alignItems: "start",
+ };
+ const sectionStyle = {
+ display: "flex" as const,
+ flexDirection: "column" as const,
+ gap: "0.25rem",
+ };
+
+ return (
+
+
+
+ V1 (legacy)
+ InputNumberExperimental
+
+
+
+
Label + icon prefix, label suffix
+
+ setV1(newValue)}
+ />
+
+
+
+
+
+
Icon-only prefix
+
+ setV3(newValue)}
+ />
+
+
+
+
+
+
Prefix and suffix icons
+
+ setV5(newValue)}
+ />
+
+
+
+
+
+
Clickable suffix icon (clears the value)
+
+ setV7(undefined),
+ }}
+ value={v7}
+ onChange={(newValue: number) => setV7(newValue)}
+ />
+ setV8(undefined),
+ }}
+ value={v8}
+ onChange={setV8}
+ />
+
+
+
+
+ );
+ },
+};
diff --git a/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx
new file mode 100644
index 0000000000..9f02bf8a9d
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx
@@ -0,0 +1,534 @@
+import type {
+ FocusEvent,
+ KeyboardEvent,
+ ReactNode,
+ Ref,
+ RefObject,
+} from "react";
+import React, {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useId,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from "react";
+import { NumberField } from "@base-ui/react/number-field";
+import { Field } from "@base-ui/react/field";
+import classnames from "classnames";
+import type {
+ InputNumberExperimentalAffix,
+ InputNumberExperimentalAffixCompoundProps,
+ InputNumberExperimentalAutocomplete,
+ InputNumberExperimentalInputProps,
+ InputNumberExperimentalProps,
+ InputNumberExperimentalRef,
+ InputNumberExperimentalSize,
+ InputNumberExperimentalStepperCompoundProps,
+ InputNumberExperimentalSuffixProp,
+} from "./types";
+import styles from "./InputNumberExperimental.module.css";
+import { Button } from "../../Button";
+import { Icon } from "../../Icon";
+import type { IconNames } from "../../Icon";
+
+type AffixVariation = "prefix" | "suffix";
+
+/**
+ * Preserve user-typed decimals on blur. Without `maximumFractionDigits: 20`,
+ * `Intl.NumberFormat` rounds to 3 digits and silently truncates input.
+ */
+const DEFAULT_FORMAT: Intl.NumberFormatOptions = {
+ useGrouping: false,
+ maximumFractionDigits: 20,
+};
+
+interface InputNumberExperimentalContextValue {
+ readonly autocomplete?: InputNumberExperimentalAutocomplete;
+ /**
+ * `true` when the consumer passed compound children (so they own the layout
+ * and the `<.Input>` compound shouldn't auto-render the stepper inside its
+ * wrapper). `false` for the default render path.
+ */
+ readonly compoundLayout: boolean;
+ readonly disabled?: boolean;
+ readonly id: string;
+ readonly innerInputRef: RefObject;
+ readonly keyboard?: "numeric" | "decimal";
+ readonly maxLength?: number;
+ readonly onBlur?: (event?: FocusEvent) => void;
+ readonly onFocus?: (event?: FocusEvent) => void;
+ readonly onKeyDown: (event: KeyboardEvent) => void;
+ readonly onKeyUp?: (event: KeyboardEvent) => void;
+ readonly placeholder?: string;
+ readonly readonly?: boolean;
+ readonly showMiniLabel: boolean;
+ readonly showStepper: boolean;
+ readonly size: InputNumberExperimentalSize;
+}
+
+const InputNumberExperimentalContext =
+ createContext(null);
+
+function useInputNumberExperimentalContext(
+ consumer: string,
+): InputNumberExperimentalContextValue {
+ const context = useContext(InputNumberExperimentalContext);
+
+ if (context === null) {
+ throw new Error(
+ ` must be used inside .`,
+ );
+ }
+
+ return context;
+}
+
+function InputNumberExperimentalInternal(
+ props: InputNumberExperimentalProps,
+ ref: Ref,
+) {
+ const {
+ align,
+ autocomplete,
+ children,
+ description,
+ disabled,
+ error,
+ id: idProp,
+ inline,
+ invalid,
+ keyboard,
+ max,
+ maxLength,
+ min,
+ name,
+ onBlur,
+ onChange,
+ onEnter,
+ onFocus,
+ onKeyDown,
+ onKeyUp,
+ placeholder,
+ prefix,
+ readonly,
+ showMiniLabel = true,
+ size = "default",
+ suffix,
+ value,
+ } = props;
+
+ const generatedId = useId();
+ const id = idProp ?? generatedId;
+
+ const innerInputRef = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ focus: () => innerInputRef.current?.focus(),
+ blur: () => innerInputRef.current?.blur(),
+ }),
+ [],
+ );
+
+ const handleValueCommitted = useCallback(
+ (newValue: number | null) => {
+ onChange?.(newValue ?? undefined);
+ },
+ [onChange],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ onKeyDown?.(event);
+
+ if (
+ event.key === "Enter" &&
+ !event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ onEnter?.(event);
+
+ // V1/V2 parity: commit on Enter. Base UI treats Enter as a
+ // navigation no-op, so round-trip focus to fire onValueCommitted
+ // via Base UI's onBlur and restore focus immediately after.
+ const target = event.currentTarget;
+ target.blur();
+ target.focus();
+ }
+ },
+ [onKeyDown, onEnter],
+ );
+
+ const isUsingCompoundPattern = useMemo(
+ () =>
+ React.Children.toArray(children).some(
+ child =>
+ React.isValidElement(child) &&
+ (child.type === InputNumberExperimentalInputCompound ||
+ child.type === InputNumberExperimentalAffixCompound ||
+ child.type === InputNumberExperimentalStepperCompound),
+ ),
+ [children],
+ );
+
+ const showStepper = !disabled && !readonly;
+
+ // `handleKeyDown` is a `useCallback` whose own deps include `onKeyDown` and
+ // `onEnter`, so listing it here transitively tracks both. Don't add `onEnter`
+ // or `onKeyDown` separately — they'd be redundant and an exhaustive-deps lint
+ // would flag the duplication.
+ const contextValue = useMemo(
+ () => ({
+ autocomplete,
+ compoundLayout: isUsingCompoundPattern,
+ disabled,
+ id,
+ innerInputRef,
+ keyboard,
+ maxLength,
+ onBlur,
+ onFocus,
+ onKeyDown: handleKeyDown,
+ onKeyUp,
+ placeholder,
+ readonly,
+ showMiniLabel,
+ showStepper,
+ size,
+ }),
+ [
+ autocomplete,
+ isUsingCompoundPattern,
+ disabled,
+ id,
+ keyboard,
+ maxLength,
+ onBlur,
+ onFocus,
+ handleKeyDown,
+ onKeyUp,
+ placeholder,
+ readonly,
+ showMiniLabel,
+ showStepper,
+ size,
+ ],
+ );
+
+ const fieldInvalid = invalid || Boolean(error);
+
+ return (
+
+
+
+
+ {isUsingCompoundPattern
+ ? children
+ : renderDefaultComposition({ prefix, suffix, size })}
+
+
+
+ {(description || error) && (
+
+ {description && (
+
+ {description}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ );
+}
+
+function renderDefaultComposition({
+ prefix,
+ suffix,
+ size,
+}: {
+ readonly prefix?: InputNumberExperimentalAffix;
+ readonly suffix?: InputNumberExperimentalSuffixProp;
+ readonly size: InputNumberExperimentalSize;
+}): ReactNode {
+ return (
+ <>
+ {prefix?.icon && (
+
+ )}
+ {prefix?.label && (
+
+ )}
+
+ {suffix?.label && (
+
+ )}
+ {suffix?.icon && (
+
+ )}
+ >
+ );
+}
+
+function InputNumberExperimentalInputCompound(
+ props: InputNumberExperimentalInputProps = {},
+) {
+ const ctx = useInputNumberExperimentalContext("Input");
+ const labelText = props.placeholder ?? ctx.placeholder;
+
+ return (
+
+
+ {labelText && (
+
+ )}
+ {!ctx.compoundLayout && }
+
+ );
+}
+
+function InputNumberExperimentalAffixCompound({
+ variation,
+ label,
+ icon,
+ onClick,
+ ariaLabel,
+}: InputNumberExperimentalAffixCompoundProps) {
+ const ctx = useInputNumberExperimentalContext("Affix");
+
+ if (!icon && !label) {
+ return null;
+ }
+
+ return (
+
+ {icon && (
+
+ )}
+ {label &&
{label}}
+
+ );
+}
+
+function InputNumberExperimentalStepperCompound({
+ incrementLabel,
+ decrementLabel,
+}: InputNumberExperimentalStepperCompoundProps = {}) {
+ const ctx = useInputNumberExperimentalContext("Stepper");
+
+ if (!ctx.showStepper) {
+ return null;
+ }
+
+ const labelTarget = ctx.placeholder ?? "value";
+ const resolvedIncrementLabel = incrementLabel ?? `Increase ${labelTarget}`;
+ const resolvedDecrementLabel = decrementLabel ?? `Decrease ${labelTarget}`;
+
+ return (
+
+
+ ▲
+
+
+ ▼
+
+
+ );
+}
+
+interface AffixIconSlotProps {
+ readonly variation: AffixVariation;
+ readonly icon: IconNames;
+ readonly size: InputNumberExperimentalSize;
+ readonly onClick?: () => void;
+ readonly ariaLabel?: string;
+}
+
+function AffixIconSlot({
+ variation,
+ icon,
+ size,
+ onClick,
+ ariaLabel,
+}: AffixIconSlotProps) {
+ return (
+
+ );
+}
+
+interface AffixIconContentProps {
+ readonly icon: IconNames;
+ readonly size: InputNumberExperimentalSize;
+ readonly onClick?: () => void;
+ readonly ariaLabel?: string;
+}
+
+function AffixIconContent({
+ icon,
+ size,
+ onClick,
+ ariaLabel,
+}: AffixIconContentProps) {
+ const iconSize = size === "small" ? "small" : "base";
+
+ if (onClick) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+interface AffixLabelSlotProps {
+ readonly variation: AffixVariation;
+ readonly label: string;
+}
+
+function AffixLabelSlot({ variation, label }: AffixLabelSlotProps) {
+ return (
+
+ {label}
+
+ );
+}
+
+function resolveAutocomplete(
+ autocomplete: InputNumberExperimentalAutocomplete | undefined,
+): string | undefined {
+ if (autocomplete === false) return "off";
+ if (autocomplete === true || autocomplete === undefined) return undefined;
+
+ return autocomplete;
+}
+
+const InputNumberExperimentalBase = forwardRef<
+ InputNumberExperimentalRef,
+ InputNumberExperimentalProps
+>(InputNumberExperimentalInternal);
+InputNumberExperimentalBase.displayName = "InputNumberExperimental";
+
+InputNumberExperimentalInputCompound.displayName =
+ "InputNumberExperimental.Input";
+InputNumberExperimentalAffixCompound.displayName =
+ "InputNumberExperimental.Affix";
+InputNumberExperimentalStepperCompound.displayName =
+ "InputNumberExperimental.Stepper";
+
+export const InputNumberExperimental = Object.assign(
+ InputNumberExperimentalBase,
+ {
+ Input: InputNumberExperimentalInputCompound,
+ Affix: InputNumberExperimentalAffixCompound,
+ Stepper: InputNumberExperimentalStepperCompound,
+ },
+);
+
+export type {
+ InputNumberExperimentalAffix,
+ InputNumberExperimentalProps,
+ InputNumberExperimentalRef,
+ InputNumberExperimentalSuffix,
+ InputNumberExperimentalSuffixProp,
+} from "./types";
diff --git a/packages/components/src/primitives/InputNumberExperimental/index.ts b/packages/components/src/primitives/InputNumberExperimental/index.ts
new file mode 100644
index 0000000000..918b422947
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/index.ts
@@ -0,0 +1,6 @@
+export { InputNumberExperimental } from "./InputNumberExperimental";
+export type {
+ InputNumberExperimentalAffix,
+ InputNumberExperimentalProps,
+ InputNumberExperimentalRef,
+} from "./types";
diff --git a/packages/components/src/primitives/InputNumberExperimental/tests/InputNumberExperimental.test.tsx b/packages/components/src/primitives/InputNumberExperimental/tests/InputNumberExperimental.test.tsx
new file mode 100644
index 0000000000..3d8a2a3966
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/tests/InputNumberExperimental.test.tsx
@@ -0,0 +1,717 @@
+import React, { createRef, useState } from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { InputNumberExperimental } from "../InputNumberExperimental";
+import type { InputNumberExperimentalRef } from "../types";
+
+// eslint-disable-next-line max-statements
+describe("InputNumberExperimental", () => {
+ describe("Rendering", () => {
+ it("renders an input queryable as a textbox with Base UI's number-field aria-roledescription", () => {
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+
+ expect(input).toBeVisible();
+ expect(input).toHaveAttribute("aria-roledescription", "Number field");
+ });
+
+ it("displays a controlled value of 0 as the visible string '0'", () => {
+ render();
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("0");
+ });
+
+ it("displays a controlled non-zero value", () => {
+ render();
+
+ expect(screen.getByRole("textbox", { name: "Quantity" })).toHaveValue(
+ "42",
+ );
+ });
+
+ it("displays the placeholder as the floating mini-label when value is empty", () => {
+ render();
+
+ const label = screen.getByText("Count");
+ expect(label.tagName).toBe("LABEL");
+ });
+
+ it("renders with auto width when inline is set", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstChild).toHaveClass("container");
+ expect(container.firstChild).toHaveClass("inline");
+ });
+
+ it("renders label-only prefix and suffix when provided via props", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("$")).toBeVisible();
+ expect(screen.getByText("USD")).toBeVisible();
+ });
+ });
+
+ describe("Affixes", () => {
+ it("renders an icon-only prefix when provided via props", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("search")).toBeInTheDocument();
+ });
+
+ it("renders both an icon and a label when prefix has both", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("invoice")).toBeInTheDocument();
+ expect(screen.getByText("$")).toBeVisible();
+ expect(screen.getByText(".00")).toBeVisible();
+ });
+
+ it("renders the suffix icon as a button when onClick is provided", async () => {
+ const user = userEvent.setup();
+ const onClick = jest.fn();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Open calendar" });
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(["small", "large"] as const)(
+ "applies %s size class to the field wrapper so affix labels inherit the variant's --field--padding-* values",
+ size => {
+ // Affix labels render as flex siblings of `.inputWrapper` (not
+ // descendants), so the size class needs to live on the outer
+ // `.wrapper` to cascade variant padding to them. Without this the
+ // affix label keeps the base 14px top/bottom padding and stretches
+ // the whole field to ~48px instead of the small 36px / large 64px
+ // height.
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".wrapper")).toHaveClass(size);
+ },
+ );
+ });
+
+ describe("Field state", () => {
+ it("hides the stepper when disabled", () => {
+ render();
+
+ expect(
+ screen.queryByRole("button", { name: /increase/i }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /decrease/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the stepper when readonly", () => {
+ render();
+
+ expect(
+ screen.queryByRole("button", { name: /increase/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the error message in an alert when error is provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "That number is too small",
+ );
+ });
+
+ it("applies invalid styling without rendering an error message when invalid is true", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstChild).toHaveAttribute("data-invalid");
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("renders the description below the field when provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Total per item")).toBeVisible();
+ });
+ });
+
+ describe("Controlled value", () => {
+ it("reflects parent re-renders for a controlled value", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("1");
+
+ rerender();
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("5");
+ });
+
+ it("clears the visible input when value transitions to undefined", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("5");
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("");
+ });
+
+ it("treats a value of null the same as an empty controlled input", () => {
+ render();
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveValue("");
+ });
+ });
+
+ describe("Decimal preservation", () => {
+ it("preserves typed decimals on blur instead of rounding to 3 fractional digits", async () => {
+ const user = userEvent.setup();
+
+ const Wrapper = () => {
+ const [value, setValue] = useState(undefined);
+
+ return (
+
+ );
+ };
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Pi" });
+ await user.type(input, "3.14159265");
+ await user.tab();
+
+ expect(input).toHaveValue("3.14159265");
+ });
+ });
+
+ describe("Keyboard inputMode", () => {
+ it.each([
+ ["numeric", "numeric"],
+ ["decimal", "decimal"],
+ ] as const)(
+ "applies inputMode=%s when keyboard=%s",
+ (keyboard, expected) => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveAttribute(
+ "inputmode",
+ expected,
+ );
+ },
+ );
+ });
+
+ describe("allowOutOfRange", () => {
+ it("clamps stepper interactions to max even though typed values can exceed it", async () => {
+ const user = userEvent.setup();
+ const onChangeSpy = jest.fn();
+
+ // Drives state from the spy so the controlled `value` reflects the
+ // committed input — without this, Base UI re-syncs the visible text to
+ // the (stale) `value` prop after blur and `999` snaps back to `5`.
+ const Wrapper = () => {
+ const [value, setValue] = useState(5);
+
+ return (
+ {
+ onChangeSpy(next);
+ setValue(next);
+ }}
+ placeholder="Count"
+ value={value}
+ />
+ );
+ };
+ render();
+
+ // Stepper increment is disabled at max, so clicking it should not push past.
+ expect(
+ screen.getByRole("button", { name: "Increase Count" }),
+ ).toBeDisabled();
+
+ // Typed values outside the range remain visible (allowOutOfRange).
+ const input = screen.getByRole("textbox", { name: "Count" });
+ await user.clear(input);
+ await user.type(input, "999");
+ await user.tab();
+
+ expect(input).toHaveValue("999");
+ expect(onChangeSpy).toHaveBeenLastCalledWith(999);
+ });
+ });
+
+ describe("onChange contract", () => {
+ it("emits a number once typed input is committed (blur)", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ await user.type(input, "42");
+ await user.tab();
+
+ expect(onChange).toHaveBeenLastCalledWith(42);
+ });
+
+ it("emits undefined when a controlled input is cleared and committed", async () => {
+ const user = userEvent.setup();
+ const onChangeSpy = jest.fn();
+
+ const Wrapper = () => {
+ const [value, setValue] = useState(7);
+
+ return (
+ {
+ onChangeSpy(next);
+ setValue(next);
+ }}
+ placeholder="Count"
+ value={value}
+ />
+ );
+ };
+
+ render();
+ const input = screen.getByRole("textbox", { name: "Count" });
+ await user.clear(input);
+ await user.tab();
+
+ expect(onChangeSpy).toHaveBeenLastCalledWith(undefined);
+ });
+
+ it("never emits NaN when the input is cleared and committed", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ await user.clear(input);
+ await user.tab();
+
+ const nanCalls = onChange.mock.calls.filter(
+ ([value]) => typeof value === "number" && Number.isNaN(value),
+ );
+ expect(nanCalls).toEqual([]);
+ });
+ });
+
+ /**
+ * Locks down the V1 / V2 InputNumber timing contract: `onChange` waits for
+ * a commit (blur, Enter, stepper press, arrow step) and never fires on
+ * individual keystrokes. This is the regression we hit when the primitive
+ * shipped with `onChange` wired to Base UI's per-keystroke `onValueChange`
+ * and every digit typed into a Harbour `TableIdFilter` fired a GraphQL
+ * request before the user finished typing the account ID.
+ */
+ describe("onChange timing contract", () => {
+ it("does not call onChange while the user is typing", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByRole("textbox", { name: "Count" }), "42");
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("calls onChange exactly once when the user types and blurs", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByRole("textbox", { name: "Count" }), "42");
+ await user.tab();
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(42);
+ });
+
+ it("calls onChange when the stepper commits a new value", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Increase Count" }));
+
+ expect(onChange).toHaveBeenCalledWith(4);
+ });
+
+ it("calls onChange when Enter is pressed", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.type(
+ screen.getByRole("textbox", { name: "Count" }),
+ "42{Enter}",
+ );
+
+ expect(onChange).toHaveBeenCalledWith(42);
+ });
+ });
+
+ describe("Stepper", () => {
+ it("increments the value by 1 when increment is clicked", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Increase Count" }));
+
+ expect(onChange).toHaveBeenLastCalledWith(4);
+ });
+
+ it("decrements the value by 1 when decrement is clicked", async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Decrease Count" }));
+
+ expect(onChange).toHaveBeenLastCalledWith(2);
+ });
+
+ it("disables the increment button when at max", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Increase Count" }),
+ ).toBeDisabled();
+ });
+
+ it("disables the decrement button when at min", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Decrease Count" }),
+ ).toBeDisabled();
+ });
+ });
+
+ describe("Keyboard", () => {
+ it("calls onEnter when Enter is pressed without modifiers", () => {
+ const onEnter = jest.fn();
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(onEnter).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["shift", { shiftKey: true }],
+ ["ctrl", { ctrlKey: true }],
+ ["meta", { metaKey: true }],
+ ])(
+ "does not call onEnter when Enter is pressed with %s",
+ (_label, modifier) => {
+ const onEnter = jest.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ fireEvent.keyDown(input, { key: "Enter", ...modifier });
+
+ expect(onEnter).not.toHaveBeenCalled();
+ },
+ );
+
+ it("forwards onKeyDown and onKeyUp", () => {
+ const onKeyDown = jest.fn();
+ const onKeyUp = jest.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ fireEvent.keyDown(input, { key: "5" });
+ fireEvent.keyUp(input, { key: "5" });
+
+ expect(onKeyDown).toHaveBeenCalledTimes(1);
+ expect(onKeyUp).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Focus / blur via ref", () => {
+ it("focuses the underlying input via ref.current.focus()", () => {
+ const ref = createRef();
+ render();
+
+ ref.current?.focus();
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toHaveFocus();
+ });
+
+ it("blurs the underlying input via ref.current.blur()", () => {
+ const ref = createRef();
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ input.focus();
+ expect(input).toHaveFocus();
+
+ ref.current?.blur();
+ expect(input).not.toHaveFocus();
+ });
+
+ it("fires onFocus and onBlur as expected", async () => {
+ const user = userEvent.setup();
+ const onFocus = jest.fn();
+ const onBlur = jest.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ await user.click(input);
+ expect(onFocus).toHaveBeenCalled();
+
+ await user.tab();
+ expect(onBlur).toHaveBeenCalled();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("wires the label to the input via the provided id", () => {
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ const label = screen.getByText("Count");
+
+ expect(input).toHaveAttribute("id", "custom-id");
+ expect(label).toHaveAttribute("for", "custom-id");
+ });
+
+ it("auto-generates an id and wires the label when id is not provided", () => {
+ render();
+
+ const input = screen.getByRole("textbox", { name: "Count" });
+ const label = screen.getByText("Count");
+
+ const id = input.getAttribute("id");
+ expect(id).toBeTruthy();
+ expect(label).toHaveAttribute("for", id ?? "");
+ });
+
+ it("uses the placeholder in stepper aria-labels", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "Increase Count" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Decrease Count" }),
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to 'value' in stepper aria-labels when no placeholder is provided", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "Increase value" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Decrease value" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Compound pattern", () => {
+ it("renders the default composition when no compound children are provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toBeVisible();
+ expect(screen.getByText("$")).toBeVisible();
+ expect(screen.getByText("items")).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Increase Count" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders compound children inside the field group when provided", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Count" })).toBeVisible();
+ expect(screen.getByText("items")).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Increase Count" }),
+ ).toBeInTheDocument();
+ });
+
+ it("lets compound .Input override the floating label via its own placeholder", () => {
+ render(
+
+
+
+ ,
+ );
+
+ const label = screen.getByText("Quantity");
+ expect(label.tagName).toBe("LABEL");
+ expect(screen.getByRole("textbox", { name: "Quantity" })).toBeVisible();
+ });
+
+ it("ignores prefix/suffix props when compound children are provided", () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.queryByText("ROOT-PREFIX")).not.toBeInTheDocument();
+ expect(screen.queryByText("ROOT-SUFFIX")).not.toBeInTheDocument();
+ expect(screen.getByText("$")).toBeVisible();
+ expect(screen.getByText("items")).toBeVisible();
+ });
+
+ it("supports user-provided stepper aria-label overrides", () => {
+ render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "Up" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Down" })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/components/src/primitives/InputNumberExperimental/types.ts b/packages/components/src/primitives/InputNumberExperimental/types.ts
new file mode 100644
index 0000000000..41671f77e8
--- /dev/null
+++ b/packages/components/src/primitives/InputNumberExperimental/types.ts
@@ -0,0 +1,169 @@
+import type { FocusEvent, KeyboardEvent, ReactNode } from "react";
+import type { IconNames } from "../../Icon";
+
+/**
+ * Affix descriptor used by the `prefix` and `suffix` props on
+ * `` for the default (drop-in) render.
+ *
+ * At least one of `label` or `icon` should be provided. Icons render outside
+ * the input area (in the field's outer padding zone), labels render inside the
+ * input wrapper directly adjacent to the typed value (matching V1).
+ *
+ * `onClick` and `ariaLabel` are not allowed in this shape; if you need a
+ * clickable action, use `InputNumberExperimentalSuffix` instead (only valid
+ * for `suffix`).
+ */
+export interface InputNumberExperimentalAffix {
+ readonly label?: string;
+ readonly icon?: IconNames;
+ readonly ariaLabel?: never;
+ readonly onClick?: never;
+}
+
+/**
+ * Suffix variant that turns the suffix icon into a clickable action button.
+ * Mirrors V1's `Suffix` type. The `?: never` fields make this a discriminated
+ * union with `InputNumberExperimentalAffix`.
+ */
+export interface InputNumberExperimentalSuffix {
+ readonly label?: string;
+ readonly icon: IconNames;
+ readonly ariaLabel: string;
+ onClick(): void;
+}
+
+/**
+ * Tagged union for the `suffix` prop. Discriminated by whether `onClick` is set:
+ * - omit `onClick` → behaves like `InputNumberExperimentalAffix`
+ * - set `onClick` → requires `icon` and `ariaLabel` (clickable action button)
+ */
+export type InputNumberExperimentalSuffixProp =
+ | InputNumberExperimentalAffix
+ | InputNumberExperimentalSuffix;
+
+export type InputNumberExperimentalAutocomplete =
+ | boolean
+ | "on"
+ | "off"
+ | "one-time-code"
+ | "address-line1"
+ | "address-line2";
+
+export type InputNumberExperimentalSize = "small" | "default" | "large";
+
+export interface InputNumberExperimentalProps {
+ readonly align?: "center" | "right";
+ readonly autocomplete?: InputNumberExperimentalAutocomplete;
+ /**
+ * Optional compound children. When children include any
+ * ``, the component renders
+ * them inside `NumberField.Root`/`NumberField.Group` and ignores `prefix`/`suffix`
+ * props. Without compound children, the component renders the default
+ * composition (input + animated mini-label + optional prefix/suffix + stepper).
+ */
+ readonly children?: ReactNode;
+ readonly description?: ReactNode;
+ readonly disabled?: boolean;
+ /** When set, renders a styled error message below the field. */
+ readonly error?: string;
+ readonly id?: string;
+ /** Shrink-wrap the field to its content (auto width). */
+ readonly inline?: boolean;
+ /** Style the error border without showing an error message. */
+ readonly invalid?: boolean;
+ readonly keyboard?: "numeric" | "decimal";
+ readonly max?: number;
+ readonly maxLength?: number;
+ readonly min?: number;
+ readonly name?: string;
+ readonly onBlur?: (event?: FocusEvent) => void;
+ /**
+ * Fires when the user **commits** a value — on blur, Enter, stepper press,
+ * or arrow-key step. Mirrors the V1 / V2 `InputNumber` `onChange` timing so
+ * consumers doing side effects (network requests, redraws) only fire on
+ * completed input rather than on every keystroke.
+ *
+ * - Emits `number` for parseable committed input.
+ * - Emits `undefined` when the field is committed empty.
+ */
+ readonly onChange?: (newValue: number | undefined) => void;
+ /**
+ * Derived from `onKeyDown` — fires when the user presses Enter without
+ * any modifier keys (Shift/Ctrl/Meta).
+ */
+ readonly onEnter?: (event: KeyboardEvent) => void;
+ readonly onFocus?: (event?: FocusEvent) => void;
+ readonly onKeyDown?: (event: KeyboardEvent) => void;
+ readonly onKeyUp?: (event: KeyboardEvent) => void;
+ readonly placeholder?: string;
+ readonly prefix?: InputNumberExperimentalAffix;
+ readonly readonly?: boolean;
+ /** Default `true`. When `false`, the floating mini-label is hidden. */
+ readonly showMiniLabel?: boolean;
+ readonly size?: InputNumberExperimentalSize;
+ readonly suffix?: InputNumberExperimentalSuffixProp;
+ /**
+ * Value of the field. Controlled-only — pair with `onChange` and drive from
+ * outside state.
+ *
+ * - `number` — controlled with that value.
+ * - `null` (or `undefined`) — controlled with an empty value. Useful for
+ * controlled forms whose form-state default is empty/cleared.
+ */
+ readonly value?: number | null;
+}
+
+export interface InputNumberExperimentalRef {
+ blur(): void;
+ focus(): void;
+}
+
+export interface InputNumberExperimentalInputProps {
+ /**
+ * Overrides the floating mini-label text. Falls back to the root component's
+ * `placeholder` prop when omitted.
+ */
+ readonly placeholder?: string;
+}
+
+export interface InputNumberExperimentalAffixCompound {
+ readonly variation: "prefix" | "suffix";
+ readonly label?: string;
+ readonly icon?: IconNames;
+ readonly ariaLabel?: never;
+ readonly onClick?: never;
+}
+
+export interface InputNumberExperimentalAffixCompoundClickable {
+ readonly variation: "prefix" | "suffix";
+ readonly label?: string;
+ readonly icon: IconNames;
+ readonly ariaLabel: string;
+ onClick(): void;
+}
+
+/**
+ * Compound `.Affix` props. Discriminated by whether `onClick` is set:
+ * - omit `onClick` -> behaves like `InputNumberExperimentalAffixCompound`
+ * - set `onClick` -> requires `icon` and `ariaLabel` (clickable icon-button)
+ *
+ * Unlike the default `prefix`/`suffix` API, the compound version does not
+ * structurally split label and icon; compose two `.Affix` elements or fall
+ * back to the default API when you need that.
+ */
+export type InputNumberExperimentalAffixCompoundProps =
+ | InputNumberExperimentalAffixCompound
+ | InputNumberExperimentalAffixCompoundClickable;
+
+export interface InputNumberExperimentalStepperCompoundProps {
+ /**
+ * Override the increment button's accessible label.
+ * Defaults to `Increase ${placeholder ?? "value"}`.
+ */
+ readonly incrementLabel?: string;
+ /**
+ * Override the decrement button's accessible label.
+ * Defaults to `Decrease ${placeholder ?? "value"}`.
+ */
+ readonly decrementLabel?: string;
+}
diff --git a/packages/components/src/primitives/index.ts b/packages/components/src/primitives/index.ts
index 95a1082255..5304778bc0 100644
--- a/packages/components/src/primitives/index.ts
+++ b/packages/components/src/primitives/index.ts
@@ -9,3 +9,9 @@
export * from "./OverlaySeparator/index";
export * from "./BottomSheet/index";
export * from "./HelperText/index";
+export { InputNumberExperimental } from "./InputNumberExperimental";
+export type {
+ InputNumberExperimentalAffix,
+ InputNumberExperimentalProps,
+ InputNumberExperimentalRef,
+} from "./InputNumberExperimental";