Skip to content

feat(components): add InputNumberExperimental primitive on Base UI#3137

Merged
nad182 merged 17 commits into
masterfrom
JOB-145723/input-number-experimental
May 8, 2026
Merged

feat(components): add InputNumberExperimental primitive on Base UI#3137
nad182 merged 17 commits into
masterfrom
JOB-145723/input-number-experimental

Conversation

@nad182
Copy link
Copy Markdown
Contributor

@nad182 nad182 commented May 6, 2026

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 v2 rebuild 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?

  1. Fix: a "hidden" build-pipeline bug where styled primitives' CSS module class hashes ended up in the JS bundle but not in the extracted styles.css. Affects every styled primitive, including the existing OverlaySeparator, BottomSheet, and HelperText. I noticed it when the stylesheet wasn't getting applied to the InputNumber instances in the consumer apps. We didn't catch the bug earlier because no consumers have been using any of the other "styled" primitives yet.
  2. Add: the new component (source, types, CSS module, tests, barrel) and export it from @jobber/components/primitives.
  3. Add: the Storybook stories for easier visual QA and comparisons between the V1 and the replacement "Experimental" component.

<InputNumberExperimental> API surface: supports composition with "sensible defaults"

  • Wraps @base-ui/react/number-field with Atlantis design tokens
  • Default render covers an animated mini-label, hover/focus-revealed stepper, optional prefix/suffix (label and/or icon), and Field.Root-composed description and error rendering so labels, descriptions, and errors wire up through Base UI's accessibility primitives
  • Compound API (<.Input>, <.Affix>, <.Stepper>) lets consumers compose the parts manually with sensible context-driven defaults
  • Controlled API: value: number | null paired with onChange: (value: number | undefined) => void. onChange is commit-only (fires on blur, Enter, stepper press, or arrow step) so it matches V1 / V2 timing — consumers wiring onChange to side effects like network requests fire on completed input rather than on every keystroke
  • allowOutOfRange is on by default so typed values outside min/max stay visible and the consumer's validation can show a message; stepper interactions still clamp
  • Composes inside InputGroup: the wrapper reads --public-field--*-radius via var(name, fallback) so adjacent siblings in a horizontal InputGroup continue to flatten their inner corners

Consumer Impact

  • Impact: Low to Medium: there no existing API changes, so this component should serve as a "direct substitute" for the V1
  • Action required: ready for replacing consumers migrating off v1
  • Breaking change (compared to V1): No

The build-pipeline fix retroactively makes the existing primitives' styles flow into dist/styles.css correctly. Nothing else in the public API changes.

Validation

  • 50 interaction tests covering rendering, affixes, controlled value, onChange commit-timing contract, stepper bounds, keyboard (Enter / modifier handling), focus / blur via ref, accessibility (label wiring, stepper aria-labels), and the compound pattern
  • Storybook stories: Basic, WithSuffixLabel, WithPrefixLabelAndSuffixLabel, InvoiceTotal_V1Parity, PrefixIconOnly, PrefixAndSuffixIcons, ClickableSuffixIcon, WithDescriptionAndError, AllSizes, Compound, V1ComparisonGrid
  • The V1ComparisonGrid story 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)
  • Smoke-tested in a downstream consumer across 10 real call sites covering all sizes, controlled patterns, and suffix-label usage

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, allowOutOfRange default, the compound-pattern detection logic).

Changes can be tested via Pre-release.

- 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
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 6, 2026

Deploying atlantis with  Cloudflare Pages  Cloudflare Pages

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

View logs

nad182 added 2 commits May 6, 2026 15:25
- 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
@nad182 nad182 force-pushed the JOB-145723/input-number-experimental branch from a460891 to b92e7b7 Compare May 6, 2026 19:26
Comment thread packages/components/rollup.config.mjs Outdated
* 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"];
Copy link
Copy Markdown
Contributor Author

@nad182 nad182 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor Author

@nad182 nad182 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor Author

@nad182 nad182 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor Author

@nad182 nad182 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
image image

* - `null` (or `undefined`) — controlled with an empty value. Useful for
* controlled forms whose form-state default is empty/cleared.
*/
readonly value?: number | null;
Copy link
Copy Markdown
Contributor Author

@nad182 nad182 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

Published Pre-release for a8e11dc with versions:

  - @jobber/components@7.8.1-JOB-145723-a8e11dc.15+a8e11dc30

To install the new version(s) for Web run:

pnpm add @jobber/components@7.8.1-JOB-145723-a8e11dc.15+a8e11dc30

- 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
@nad182 nad182 marked this pull request as ready for review May 7, 2026 15:55
@nad182 nad182 requested a review from a team as a code owner May 7, 2026 15:55
@nad182 nad182 requested review from Copilot and jdeichert May 7, 2026 15:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 InputNumberExperimental primitive (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_CSS builds 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.

Comment thread packages/components/rollup.config.mjs Outdated
* 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"];
Comment on lines +133 to +140
export interface InputNumberExperimentalAffixCompoundProps {
readonly variation: "prefix" | "suffix";
readonly label?: string;
readonly icon?: IconNames;
readonly ariaLabel?: string;
onClick?(): void;
}

Comment on lines +430 to +439
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}`;

Comment on lines +174 to +194
/**
* 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);
}
nad182 and others added 10 commits May 7, 2026 14:37
…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.
…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
nad182 added 2 commits May 7, 2026 20:20
…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
Copy link
Copy Markdown
Contributor

@MichaelParadis MichaelParadis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@nad182 nad182 merged commit 298d3cf into master May 8, 2026
14 checks passed
@nad182 nad182 deleted the JOB-145723/input-number-experimental branch May 8, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants