Skip to content

feat(components): add onValueChange prop to InputNumberExperimental#3147

Merged
nad182 merged 3 commits into
masterfrom
JOB-145723/input-number-experimental-onvaluechange
May 12, 2026
Merged

feat(components): add onValueChange prop to InputNumberExperimental#3147
nad182 merged 3 commits into
masterfrom
JOB-145723/input-number-experimental-onvaluechange

Conversation

@nad182
Copy link
Copy Markdown
Contributor

@nad182 nad182 commented May 12, 2026

Why Is This Changing?

InputNumberExperimental.onChange deliberately commits on blur / Enter / stepper / arrow step to mirror V1 / V2 InputNumber timing. That keeps side effects like network requests, URL updates, and form-state writes from firing on every keystroke, which is the right default for the vast majority of consumers.

It's wrong for one legitimate case: UI that needs to reflect in-progress input as the user types (e.g. a subtotal that updates live as a discount is typed). Today there's no first-party way to opt in, so consumers reach for raw onKeyUp and parse event.currentTarget.value themselves, losing the parsed-number / empty-input contract that onChange provides.

What Is Changing?

New opt-in onValueChange prop. Same (newValue: number | undefined) => void signature as onChange, fires per keystroke (also on stepper / paste). Wires straight through to Base UI's NumberField.onValueChange. onChange's commit-only timing is unchanged.

Also tightened the verbose onChange JSDoc and added a sibling JSDoc on onValueChange that points consumers to the right callback for their use case.

Consumer Impact

  • Impact: Low — purely additive, opt-in
  • Action required: None
  • Breaking change: No

Validation

  • 4 new tests in InputNumberExperimental.test.tsx under a new onValueChange contract describe:
    • fires per keystroke with the parsed number while the user types
    • emits undefined when the field is cleared
    • does not bleed into onChange's commit-only timing when both callbacks are wired
    • fires on stepper interactions with the new value
  • Existing 51 tests remain green (55 total pass)
  • ESLint clean — max-statements was already at the threshold for this function; added an inline disable matching the existing precedent in Menu.Legacy.tsx

Reviewer Notes

  • Naming follows Base UI's underlying prop name (onValueChange) so the wiring is mechanical and the API matches what most React devs reading the source would expect
  • No new files, no public type renames, no behaviour change to onChange

- Some consumers need UI to reflect in-progress input (e.g. a live subtotal that updates as the user types a discount), which `onChange`'s commit-only timing deliberately suppresses
- The new prop wires through to Base UI's per-keystroke `onValueChange`; `onChange` keeps its commit semantics
@cloudflare-workers-and-pages
Copy link
Copy Markdown

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

Deploying atlantis with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0166f37
Status: ✅  Deploy successful!
Preview URL: https://e5efb73c.atlantis.pages.dev
Branch Preview URL: https://job-145723-input-number-expe-q6ah.atlantis.pages.dev

View logs

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

Adds an opt-in onValueChange callback to InputNumberExperimental so consumers can react to per-keystroke (in-progress) parsed numeric updates, while preserving the existing commit-only onChange behavior.

Changes:

  • Added onValueChange?: (newValue: number | undefined) => void to InputNumberExperimentalProps with updated JSDoc guidance.
  • Wired onValueChange through to Base UI NumberField.Root.onValueChange (mapping null -> undefined).
  • Added a new onValueChange contract test block covering per-keystroke typing, clearing, and ensuring onChange doesn’t fire while typing when both callbacks are provided.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/components/src/primitives/InputNumberExperimental/types.ts Adds the new onValueChange prop and updates callback documentation.
packages/components/src/primitives/InputNumberExperimental/InputNumberExperimental.tsx Wires onValueChange to Base UI’s NumberField and adds a lint disable for function size.
packages/components/src/primitives/InputNumberExperimental/tests/InputNumberExperimental.test.tsx Adds tests defining the onValueChange behavior and interaction with onChange.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +87 to +89
* Fires on every parsed change as the user types. Prefer `onChange` for
* side effects; reach for this only when UI must reflect in-progress input
* (e.g. a live-updating subtotal).
Comment on lines +438 to +442
describe("onValueChange contract", () => {
it("fires per keystroke with the parsed number while the user types", async () => {
const user = userEvent.setup();
const onValueChange = jest.fn();
render(
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

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +438 to +505
describe("onValueChange contract", () => {
it("fires per keystroke with the parsed number while the user types", async () => {
const user = userEvent.setup();
const onValueChange = jest.fn();
render(
<InputNumberExperimental
onValueChange={onValueChange}
placeholder="Count"
/>,
);

await user.type(screen.getByRole("textbox", { name: "Count" }), "42");

expect(onValueChange).toHaveBeenCalledTimes(2);
expect(onValueChange).toHaveBeenNthCalledWith(1, 4);
expect(onValueChange).toHaveBeenNthCalledWith(2, 42);
});

it("emits undefined when the field is cleared", async () => {
const user = userEvent.setup();
const onValueChange = jest.fn();
render(
<InputNumberExperimental
onValueChange={onValueChange}
placeholder="Count"
value={5}
/>,
);

await user.clear(screen.getByRole("textbox", { name: "Count" }));

expect(onValueChange).toHaveBeenLastCalledWith(undefined);
});

it("does not fire onChange while the user is typing even when onValueChange is set", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const onValueChange = jest.fn();
render(
<InputNumberExperimental
onChange={onChange}
onValueChange={onValueChange}
placeholder="Count"
/>,
);

await user.type(screen.getByRole("textbox", { name: "Count" }), "42");

expect(onValueChange).toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});

it("fires on stepper interactions with the new value", async () => {
const user = userEvent.setup();
const onValueChange = jest.fn();
render(
<InputNumberExperimental
onValueChange={onValueChange}
placeholder="Count"
value={3}
/>,
);

await user.click(screen.getByRole("button", { name: "Increase Count" }));

expect(onValueChange).toHaveBeenLastCalledWith(4);
});
});
Comment on lines +88 to +89
* Prefer `onChange` for side effects; reach for this only when UI must
* reflect in-progress input (e.g. a live-updating subtotal).
@nad182 nad182 requested a review from MichaelParadis May 12, 2026 19:11
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 5ca83c9 into master May 12, 2026
15 checks passed
@nad182 nad182 deleted the JOB-145723/input-number-experimental-onvaluechange branch May 12, 2026 21:31
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