Skip to content

[number field] Add imperative control of the raw input value - #5421

Open
michaldudak wants to merge 6 commits into
mui:masterfrom
michaldudak:claude/numberfield-imperative-input-value
Open

[number field] Add imperative control of the raw input value#5421
michaldudak wants to merge 6 commits into
mui:masterfrom
michaldudak:claude/numberfield-imperative-input-value

Conversation

@michaldudak

@michaldudak michaldudak commented Aug 5, 2026

Copy link
Copy Markdown
Member

Alternative to #5412. Same problem, imperative API instead of the prop trio. Opened for comparison — these are mutually exclusive; only one should land.

Problem

value holds a parsed number, so it can't represent text that isn't a number yet. Typing -1.5 passes through - and -1., neither of which parses, and both leave value untouched. Those are legal states internally — typing - already leaves the text as - with value at null — but there was no way to reach them programmatically.

Solution

Adds actionsRef with a setInputValue action, following the pattern Popover.Root already uses.

const actionsRef = React.useRef<NumberField.Root.Actions | null>(null);

<NumberField.Root min={-100} actionsRef={actionsRef}>

actionsRef.current.setInputValue('-');

The action mirrors what typing the same string does, minus the per-character gating that exists to reject keystrokes — the caller chose the string deliberately. value follows the text when it parses, is left alone when it doesn't (so '-' can sit over an existing number without clearing it), and an empty string clears it. Changes report reason: 'imperative-action'.

Comparison with #5412

This PR #5412 (prop trio)
Diff +249 / −5, 1 file of source (+52) +1267 / −114, 6 files of source (+357 / −79)
New state none useControlled text, authorship tracking, write dedupe
Reading the current text state.inputValue only same, plus onInputValueChange
Observing text changes not possible every change, with a reason
Vetoing a change not possible eventDetails.cancel()
Driving from a reducer / form library no yes
Text is ordinary component state controllable

The imperative version is much smaller because it reuses the dirty-text machinery that already exists for typing, rather than making the text a controllable value.

The cost is that the caller can't observe or negotiate. Text set this way behaves as an unsaved edit: it survives re-renders and external value changes with no callback to say so, and the field reclaims the display on the next value-changing interaction. should survive a later external value change until an interaction reconciles it pins that, including the fact that blur does not reclaim it when the text can't parse — the field goes on showing something its value contradicts until an interaction resolves it.

That last part is a pre-existing quirk on master, not introduced here. #5412 fixes it as part of its refactor; this PR leaves it in place, which is the honest cost of the smaller diff.

Testing

9 new tests covering the intermediate-string cases, value coupling, the reason, and the reconciliation behavior above.

Full jsdom suite (7513) green, 400 jsdom / 430 chromium NumberField tests, forced tsgo -b clean, plus eslint, stylelint, prettier, valelint, and an idempotent docs:validate.

🤖 Generated with Claude Code

`value` holds a parsed number, so it cannot represent text that is not a
number yet. Typing `-1.5` passes through `-` and `-1.`, neither of which
parses, leaving no way to drive the field through those states
programmatically.

Add `actionsRef` with a `setInputValue` action, following the pattern
`Popover.Root` already uses. The action mirrors what typing the same
string does, minus the per-character gating that exists to reject
keystrokes: the text is written verbatim, `value` follows it when it
parses and is left alone when it does not, and an empty string clears.

Marking the text unsynced is what lets an intermediate string survive the
formatting sync, so the text behaves as an unsaved edit exactly as typed
text does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

commit: 31b3e07

@michaldudak michaldudak added component: number field Changes related to the number field component. type: new feature Expand the scope of the product to solve a new problem. labels Aug 5, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 5, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+325B(+0.07%) 🔺+113B(+0.08%)

Details of bundle changes

Performance

Total duration: 1,219.16 ms -28.18 ms(-2.3%) | Renders: 78 (+0) | Paint: 1,941.19 ms -6.95 ms(-0.4%)

Test Duration Renders
Checkbox mount (500 instances) 65.98 ms ▼-23.09 ms(-25.9%) 1 (+0)

14 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 31b3e07
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a7433b91d28ea00088a16ce
😎 Deploy Preview https://deploy-preview-5421--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

michaldudak and others added 4 commits August 5, 2026 14:56
Address review feedback on the `setInputValue` action:

- Commit as soon as the text parses, instead of waiting for a blur that may
  never come when the input is driven from outside the field. `keyboard`,
  `wheel` and the increment/decrement buttons already commit at the point of
  change; only typing defers, and typing implies focus. Clearing the text
  commits too, since that also changes the value.
- Keep the action working while `disabled` or `readOnly` — it is an escape
  hatch for the owner of the component, not a simulated keystroke — and say so
  in the JSDoc and the docs instead of implying it mirrors typing.

Docs also now state that `value` is clamped even though the text isn't, and
describe the actual reclaim rule: blur ends the unsaved edit and re-arms the
formatting sync, so the display returns to the formatted value on the next
render, not on the next value-changing interaction.

Tests pin the commit timing, the `disabled`/`readOnly` bypass, clamping,
format-relative parsing, and the post-blur reclaim. The intermediate-string
test now uses `min={0}`, where typing `-` is rejected, so it actually covers
the gating bypass it claims to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`setInputValue` accepted any string, so it could put the field into states a
user could never type into it. It now runs the same per-character validation
the typed path runs, and ignores text that fails it.

The predicate moves to `isValidInputString` in the number field's parse utils
and is called from both `NumberFieldInput`'s `onChange` and the action, so the
two paths accept exactly the same strings by construction. Partial entries
like `'-'` and `'1.'` still pass, since validation is per character rather
than a parse.

A rejected call is a complete no-op — unlike the typed path it doesn't mark
the text dirty first, because there is no keystroke to swallow — and warns in
development, since silently doing nothing gives the caller no way to tell.

`disabled` and `readOnly` are still not consulted: those gate user
interaction, not the component's owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`setInputValue` branched on `isEmpty` three times, including a compound
`!isEmpty && parsedValue === null` guard that had to be read twice to see it
meant "valid text that isn't a number yet". Fold that into one `nextValue`
whose `undefined` case means "leave `value` alone", so the guard is a single
identity check.

In the tests, hoist the repeated set-text click into `clickSetText()` (it
appeared 20 times), drop the `label` prop of `SetInputValueApp` that nothing
passed, and collapse two pairs of tests that differed only in one value into
loops: the rejected-text cases and the `disabled`/`readOnly` cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak marked this pull request as ready for review August 5, 2026 14:46
@michaldudak michaldudak changed the title [number-field] Add imperative control of the raw input value [number field] Add imperative control of the raw input value Aug 5, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a7e6da37e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

//
// `disabled` and `readOnly` are still not consulted — those gate user interaction, and
// this is the component owner driving the field deliberately.
if (!isEmpty && !isValidInputString(nextInputValue, getAllowedNonNumericKeys())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject structurally invalid raw text

When callers pass text made only of allowed characters but not a valid number-field sequence, this guard lets it through even though the input key handling would block the same state. For example, actionsRef.current.setInputValue('1.2.3') passes the per-character check, then parseNumber normalizes it to 12.3, so onValueChange and the hidden submitted value become 12.3 while the visible input shows 1.2.3; validate the whole string against the same structural constraints before applying the imperative action.

Useful? React with 👍 / 👎.

@michaldudak michaldudak Aug 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, including a detail the comment doesn't spell out: the hidden input submits 12.3 while the field shows 1.2.3, so the divergence reaches form submission.

It isn't specific to the imperative action, though. The same string through a plain change event — drag-and-drop, IME, autofill — already does this on master:

fireEvent.change(input, { target: { value: '1.2.3' } })
→ visible "1.2.3", onValueChange(12.3)

isValidInputString is the onChange predicate extracted verbatim, so the action inherits the behaviour rather than introducing it. Typing is indeed blocked, but that gate lives in onKeyDown and has never applied to non-keystroke text entry.

The cause is the "keep only the last ." normalization in parseNumber (utils/parse.ts:190-193), not the character validation being too permissive. That collapse is deliberate and tested — it's what makes a European-formatted paste like 1.234.567.89 resolve to 1234567.89 in a US-locale field (utils/parse.test.ts:193-204). 1.2.3 is the same rule meeting a string that isn't plausible in any locale.

Edited: an earlier version of this reply blamed the global decimal-separator replace. The replace is global, but it's the lastIndexOf('.') collapse that produces 12.3.

Adding a structural check inside the action alone would reintroduce exactly the typed/imperative divergence this PR set out to remove, so this is better fixed once in the shared parse/validation layer, where it also fixes the drop/IME path. Tracked separately in #5424.

</NumberField.Root>;

// Puts the field into a valid intermediate state that `value` cannot express.
actionsRef.current.setInputValue('-');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a null-safe ref call in docs example

When users copy this tsx example into a strict TypeScript project, actionsRef.current is still typed as NumberField.Root.Actions | null from the declaration above, so calling .setInputValue() directly fails with a possibly-null ref error. Use optional chaining or a non-null assertion in the example so the new public API snippet compiles as shown.

Useful? React with 👍 / 👎.

// formatting sync would otherwise overwrite it with the formatting of `value` on the next
// render, exactly as it does mid-typing.
allowInputSyncRef.current = false;
setInputValue(nextInputValue);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revert raw text when imperative change is canceled

When onValueChange calls details.cancel() for an imperative-action, this branch has already disabled input sync and written nextInputValue into visible state before setValue can return false. A canceled actionsRef.current.setInputValue('42') over value 5 therefore leaves the textbox showing 42 while the stored/form value remains 5, unlike canceled keyboard changes that leave the display unchanged; move the visible text update after the cancellation check or roll it back when setValue is canceled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining — the imperative path already behaves the same as the typed one here:

canceled imperative → visible "42"
canceled typed      → visible "42"

The comparison to canceled keyboard changes isn't the right baseline. Arrow keys never write the text directly; they reach the display through the formatting sync, so cancelling naturally leaves it untouched. For direct text entry, keeping the entered text on cancel is the existing behaviour — onChange writes the text before setValue too — and the action matches it deliberately, since the text is modelled as an unsaved edit that survives until an interaction reconciles it.

Cancelling does correctly suppress the commit added in this PR: onValueCommitted fires 0 times, because setValue returns false.

`actionsRef.current` is typed `Actions | null`, so the snippet errored when
copied into a project with `strictNullChecks`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: number field Changes related to the number field component. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant