feat(components): add InputNumberExperimental primitive on Base UI#3137
Conversation
- Styled primitives under `src/primitives/` only contribute their CSS module class hashes to the bundled `styles.css` if they are reachable from a PREBUILD_CSS entry point. The main `src/index.ts` does not re-export from `primitives/`, so those hashes were absent from the stylesheet even though the JS bundles referenced them - Affects every styled primitive, not just new ones: the existing `OverlaySeparator` and `BottomSheet` had the same latent bug (their hashes were also missing from `styles.css`) - Add `src/primitives/index.ts` as an additional PREBUILD_CSS input so postcss extracts those modules during the deterministic CSS-only pass
Deploying atlantis with
|
| Latest commit: |
6e6290a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4da4f40e.atlantis.pages.dev |
| Branch Preview URL: | https://job-145723-input-number-expe.atlantis.pages.dev |
- Existing styled primitives (OverlaySeparator, BottomSheet, HelperText) cover layout-only and text cases. Consumers rebuilding off the v1 InputNumber need a numeric input on Base UI before the v2 rebuild is feature-complete (the v2 rebuild does not yet support prefix/suffix or a custom stepper) - Wraps @base-ui/react/number-field with Atlantis tokens, an animated mini-label, hover-revealed stepper, optional prefix/suffix (label and/or icon), description and error rendering, and a controlled value: number | null API - Exposes a compound API (.Input, .Affix, .Stepper) for consumers that want to compose the parts manually, with context-driven defaults when the root form is used directly - allowOutOfRange is on by default. Typed values outside min/max stay visible and let the consumer's form-level validation surface a message, matching v1 behaviour. Stepper interactions still clamp - The floating mini-label is positioned within the input area only. When a prefix label is present, the mini-label sits over the typed value rather than spanning across the prefix area. This is the natural pure-CSS positioning and avoids useLayoutEffect measurement
- Cover the affix variants (label-only, icon-only, label+icon, clickable suffix icon) so reviewers can spot visual regressions vs v1 and so consumers picking up the primitive have working call-site references - Include an AllSizes story so changes to .small or .large modifier rules are easy to eyeball across the three sizes - Include a V1ComparisonGrid story that renders the v1 InputNumber and the new primitive side-by-side, useful for the next time the affix visuals change
a460891 to
b92e7b7
Compare
| * bundle but not in the stylesheet, so consumers see no styles applied. | ||
| */ | ||
| const PREBUILD_CSS = process.env.PREBUILD_CSS === "true"; | ||
| const PREBUILD_CSS_INPUTS = ["src/index.ts", "src/primitives/index.ts"]; |
There was a problem hiding this comment.
All three existing styled primitives (OverlaySeparator, BottomSheet, and HelperText) had the same bug: their styles were bundled into the JS but missing from dist/styles.css, so consumers would see unstyled components. Adding the primitives barrel as a second CSS entry point fixes all of them at once and keeps style ordering predictable.
| > | ||
| <InputNumberExperimentalContext.Provider value={contextValue}> | ||
| <NumberField.Root | ||
| allowOutOfRange |
There was a problem hiding this comment.
allowOutOfRange is on intentionally to match V1 behaviour. Typing a value past max (or below min) keeps it visible so the consumer's form-level validation can surface a useful error message, instead of silently snapping back to the bound on blur. Stepper interactions still clamp (Base UI handles that part regardless of allowOutOfRange). Native <input type="number"> works the same way.
| } | ||
| } | ||
|
|
||
| const isUsingCompoundPattern = React.Children.toArray(children).some( |
There was a problem hiding this comment.
We use this compound component "detection pattern" in a few other components. When the consumer is using the compound API, the prefix and suffix props on the root are ignored.
I've added the test coverage for this: Compound pattern > ignores prefix/suffix props when compound children are provided.
| * in the default render — also inherit the new padding values and stay | ||
| * vertically aligned with the typed value. | ||
| */ | ||
| .wrapper:has( |
There was a problem hiding this comment.
Flagging this for visual review: when a prefix label is present (e.g. prefix={{ label: "$" }}), the mini-label position differs from V1 (ONLY seen in Storybook and NO existing consumers are affected!). V1's mini-label spans over the prefix area (sits over the $); while in this implementation it sits over the typed value only, after the prefix. See the V1ComparisonGrid story for a side-by-side.
Trade-off:
- Going pure-CSS lets us drop
useLayoutEffect, the inline CSS-variable injection, and a re-render dependency on layout. It also aligns with where the V2 rebuilds are heading. - V1 achieves its tighter visual association via JS measurement (
useFormFieldWrapperStyles.ts). I tried that approach in an earlier iteration and it worked. Switching back is reversible (~30 lines +useLayoutEffect) if the "V1 was" is a strict requirement.
I'm not strongly attached either way. Happy to revert to JS measurement if the consensus is that V1's positioning is the design we want to keep.
It could be considered as a UI/UX improvement, especially when in cases where long(er) "prefix"-es are used. But even if it's not, there are no actual usages like this right now, so this change is not going to be "destructive".
Consider the following (not the "Invoice total" mini-label placement):
| Short prefix | Long Prefix |
|---|---|
![]() |
![]() |
| * - `null` (or `undefined`) — controlled with an empty value. Useful for | ||
| * controlled forms whose form-state default is empty/cleared. | ||
| */ | ||
| readonly value?: number | null; |
There was a problem hiding this comment.
This component is "controlled-only", intentionally tighter than V1's mixed controlled / uncontrolled support. There is no defaultValue prop because Base UI always sees a value here, so defaultValue would be ignored. This aligns with with other V2 migrations; consumers needing the uncontrolled pattern can wire local useState.
null is treated the same as undefined (controlled, empty). Allowing both makes it simpler for consumers when their form state defaults to null.
|
Published Pre-release for a8e11dc with versions: To install the new version(s) for Web run: |
- The affix label was rendering with `var(--color-text--secondary)` (muted grey), making prefix/suffix text look noticeably lighter than v1's, where the affix sits at the same color as the typed value - Drop the explicit color so the label inherits `--field--value-color` (`var(--color-heading)`) from the wrapper, matching v1
There was a problem hiding this comment.
Pull request overview
This PR introduces a new InputNumberExperimental primitive built on @base-ui/react/number-field to support migration off the legacy v1 <InputNumber>, and includes a build-pipeline adjustment intended to ensure styled primitives’ CSS makes it into the extracted dist/styles.css.
Changes:
- Adds the
InputNumberExperimentalprimitive (types, CSS module, compound API, tests, Storybook stories) and exports it from the primitives barrel. - Updates the components Rollup config to include the primitives barrel during
PREBUILD_CSSbuilds to fix missing extracted CSS for styled primitives.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/components/src/primitives/InputNumberExperimental/types.ts | Defines the public prop/ref types and affix/compound API shapes. |
| packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx | Implements the Base UI number-field wrapper, default composition, and compound components. |
| packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css | Adds styling for layout, minilabel behavior, affixes, and stepper. |
| packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.module.css.d.ts | CSS module typing for the new styles. |
| packages/components/src/primitives/InputNumberExperimental/tests/InputNumberExperimental.test.tsx | Interaction/accessibility and compound-pattern tests for the new primitive. |
| packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.stories.tsx | Storybook coverage and v1 comparison grid for visual QA. |
| packages/components/src/primitives/InputNumberExperimental/index.ts | Barrel export for the new primitive and its key types. |
| packages/components/src/primitives/index.ts | Exposes InputNumberExperimental from @jobber/components/primitives. |
| packages/components/rollup.config.mjs | Adjusts PREBUILD_CSS inputs to include primitives for CSS extraction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * bundle but not in the stylesheet, so consumers see no styles applied. | ||
| */ | ||
| const PREBUILD_CSS = process.env.PREBUILD_CSS === "true"; | ||
| const PREBUILD_CSS_INPUTS = ["src/index.ts", "src/primitives/index.ts"]; |
| export interface InputNumberExperimentalAffixCompoundProps { | ||
| readonly variation: "prefix" | "suffix"; | ||
| readonly label?: string; | ||
| readonly icon?: IconNames; | ||
| readonly ariaLabel?: string; | ||
| onClick?(): void; | ||
| } | ||
|
|
| if (onClick) { | ||
| return ( | ||
| <Button | ||
| ariaLabel={ariaLabel ?? ""} | ||
| icon={icon} | ||
| onClick={onClick} | ||
| size={iconSize} | ||
| type="tertiary" | ||
| variation="subtle" | ||
| /> |
| const labelTarget = ctx.placeholder ?? "value"; | ||
| const resolvedIncrementLabel = incrementLabel ?? `Increase ${labelTarget}`; | ||
| const resolvedDecrementLabel = decrementLabel ?? `Decrease ${labelTarget}`; | ||
|
|
| /** | ||
| * 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); | ||
| } |
…umberExperimental Affix labels render as flex siblings of the input wrapper rather than descendants, so the `.small` / `.large` modifier (previously only on `.inputWrapper`) didn't cascade `--field--padding-*` to them. They kept the base 14px top/bottom padding, grew to ~48px tall, and stretched the whole field above the size variant's intended height (36px small, 64px large) -- visible on `size="small"` + `suffix` consumers like the Custom Field Area inputs, where the field rendered ~12px taller than surrounding small inputs and the typed value sat above center. Hoist the size class onto the outer `.wrapper` so the affix label siblings inherit the variant padding. The existing `.inputWrapper.small` mini-label rules still key off `.inputWrapper` so they're unaffected.
…atures from Base UI
…ntal - `--public-field--*-radius` on `.wrapper` shadowed InputGroup's per-group overrides, leaving rounded corners between adjacent fields - Read those vars via `var(name, fallback)` instead so InputGroup's parent override flows through; default rendering is unchanged - Same pattern would also clean up the V1 FormField and V2 InputNumber.rebuilt modules whenever they are next touched
…erimental - V1/V2 InputNumber fire `onChange` only on commit (blur, Enter, stepper, arrow); migrating consumers expect that timing - Earlier wiring fired on every keystroke; a numeric filter input in a downstream consumer ran one GraphQL request per digit typed - Wire `onChange` to Base UI's `onValueCommitted` instead; per-keystroke updates stay internal until a consumer needs them - Update existing onChange tests to commit through `user.tab()`, and add a timing-contract describe that pins the regression
…s disabled or readonly - Default render gated the stepper on `ctx.showStepper`, but the compound `<.Stepper>` rendered unconditionally, so disabled / readonly fields kept showing increment/decrement controls in compound mode - Move the `showStepper` check inside the component itself; default render no longer needs the duplicate guard
- `InputNumberExperimentalAffixCompoundProps` allowed `onClick` without `ariaLabel`, which let the compound API render an unlabeled icon-button for assistive tech - Split into a discriminated union (mirrors the existing `Suffix` / `Affix` shape): `onClick` now requires both `icon` and `ariaLabel` - Drop the `ariaLabel ?? ""` fallback at the render seam and gate the `<Button>` branch on both fields being set, so a cast or escape hatch can no longer produce an unlabeled button
- Both barrel inputs share the basename `index.ts`, so Rollup's `[name]`
output placeholder collides on `dist/index.{cjs,mjs}` for the array
form and one input would silently overwrite the other
- Object form gives each entry a unique key (`index`, `primitives/index`)
so the JS outputs land at distinct paths; CSS extraction is unchanged
- The earlier guard (`if (onClick && ariaLabel)`) silently rendered a non-clickable icon when ariaLabel was missing, dropping the click - Fall back to the icon name instead so the button stays clickable and screen readers get something descriptive; the public discriminated unions still enforce ariaLabel at the API surface
- Cut multi-line comments where surrounding code or types already convey the intent - Affects the var-fallback CSS, commit-only callback, compound affix types, PREBUILD config, and one allowOutOfRange test note
…perimental - Removed the trailing note on the user.tab() call in the allowOutOfRange test; the surrounding describe and assertions already convey that the input is being committed - Removed the inline note above handleValueCommitted; the variable name and the wiring to onValueCommitted self-document the timing, and the public TSDoc on onChange already explains the contract - Kept the Wrapper-explanation comment on the same test since it documents a non-obvious harness choice that protects the assertion from Base UI re-syncing visible text after blur
…abel - Replaced the icon-name fallback with a static Action string so the rendered Button never gets a noun-only label like trash or label when a consumer bypasses the typed contract - The public InputNumberExperimentalSuffix and clickable compound types already require ariaLabel when onClick is set, so this fallback is insurance against type-bypass and never fires on legitimate paths - Matches the Atlantis pattern of concrete action-describing fallbacks used by Banner and ClearAction without inventing a verb the primitive cannot know
- Base UI's NumberFieldInput treats Enter as a navigation no-op so onValueCommitted only fires on blur, ArrowUp/Down, or Home/End, leaving Enter as the only key that drops a typed value silently - V1 and V2 InputNumber both commit on Enter via react-stately's useNumberFieldState, so consumers like TableIdFilter expect their onChange to fire when the user presses Enter to submit a typed account ID - handleKeyDown now round-trips focus on unmodified Enter (blur, then immediate refocus) which fires Base UI's onBlur path and surfaces onValueCommitted with the parsed input value while preserving the editing context for the user - Added a regression test under the onChange timing contract that types into the input then presses Enter and asserts onChange fires once with the typed number


Why Is This Changing?
We are deprecating the v1
<InputNumber> and are actively replacing its instances in the consumer apps. We need a numeric input build on top of **Base UI** as a path forward, but the v2rebuild will undergo its own improvement/migration. This PR adds a styled, "experimental" primitive on top of@base-ui/react/number-field` so those consumers can stop relying on v1 ahead of the full v2 migration/upgrade. Some of my decisions were cognizant of existing usages and patterns of the current V2 component - so that (hopefully) less migration work would be required.What Is Changing?
styles.css. Affects every styled primitive, including the existingOverlaySeparator,BottomSheet, andHelperText. I noticed it when the stylesheet wasn't getting applied to theInputNumberinstances in the consumer apps. We didn't catch the bug earlier because no consumers have been using any of the other "styled" primitives yet.@jobber/components/primitives.<InputNumberExperimental>API surface: supports composition with "sensible defaults"@base-ui/react/number-fieldwith Atlantis design tokensField.Root-composed description and error rendering so labels, descriptions, and errors wire up through Base UI's accessibility primitives<.Input>,<.Affix>,<.Stepper>) lets consumers compose the parts manually with sensible context-driven defaultsvalue: number | nullpaired withonChange: (value: number | undefined) => void.onChangeis commit-only (fires on blur, Enter, stepper press, or arrow step) so it matches V1 / V2 timing — consumers wiringonChangeto side effects like network requests fire on completed input rather than on every keystrokeallowOutOfRangeis on by default so typed values outsidemin/maxstay visible and the consumer's validation can show a message; stepper interactions still clampInputGroup: the wrapper reads--public-field--*-radiusviavar(name, fallback)so adjacent siblings in a horizontalInputGroupcontinue to flatten their inner cornersConsumer Impact
The build-pipeline fix retroactively makes the existing primitives' styles flow into
dist/styles.csscorrectly. Nothing else in the public API changes.Validation
onChangecommit-timing contract, stepper bounds, keyboard (Enter / modifier handling), focus / blur via ref, accessibility (label wiring, stepper aria-labels), and the compound patternBasic,WithSuffixLabel,WithPrefixLabelAndSuffixLabel,InvoiceTotal_V1Parity,PrefixIconOnly,PrefixAndSuffixIcons,ClickableSuffixIcon,WithDescriptionAndError,AllSizes,Compound,V1ComparisonGridV1ComparisonGridstory renders the v1<InputNumber>and the new primitive side-by-side across the four affix variants (label+icon prefix, icon-only prefix, prefix+suffix icons, clickable suffix icon)Reviewer Notes
I left targeted review comments on a few specific lines that capture intentional decisions worth flagging (mini-label position vs v1 with prefix labels, controlled-only API,
allowOutOfRangedefault, the compound-pattern detection logic).Changes can be tested via Pre-release.