Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/src/app/(docs)/react/components/number-field/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,45 @@ import { NumberField } from '@base-ui/react/number-field';
</NumberField.Root>;
```

## Examples

### Setting the input text imperatively

`value` holds the 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.

To drive those states, reach for `actionsRef`:

```tsx title="Setting the input text imperatively"
const actionsRef = React.useRef<NumberField.Root.Actions | null>(null);

<NumberField.Root min={-100} actionsRef={actionsRef}>
{/* ... */}
</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 👍 / 👎.

```

The action can only reach states typing can reach.
The string runs through the same character validation as a typed one, so anything the input would reject — stray letters, symbols the current `locale` and `format` don't use — is ignored and the field is left unchanged.
Partial entries are allowed, which is the point: `'-'` and `'1.'` pass validation even though neither parses.

The text itself is displayed as given, never formatted, and never clamped.
`value` follows the text when it parses and is left alone when it doesn't, which is what lets `'-'` sit over an existing number without clearing it.
An empty string clears `value`.

Unlike the text, `value` is still validated: it is clamped to `min` and `max` unless `allowOutOfRange` is set.
Setting `'999'` on a field with `max={10}` therefore shows `999` while `value` becomes `10`.

Two things do set the action apart from typing, because the caller is the component's owner rather than a user:

- It works while the field is `disabled` or `readOnly`, which gate user interaction.
- Its changes are final rather than pending: `onValueChange` and `onValueCommitted` both run right away, with `reason: 'imperative-action'`. Waiting for blur would drop the change entirely when the input is never focused.

The _text_ still behaves as an unsaved edit, exactly as if it had been typed: it survives re-renders and external `value` changes.
Blurring the input ends the edit and re-arms formatting, so the display returns to the formatted `value` on the next render — whether or not that blur changed anything.

## API reference

import { TypesNumberField } from './types';
Expand Down
68 changes: 40 additions & 28 deletions docs/src/app/(docs)/react/components/number-field/types.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/src/app/(docs)/react/components/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,8 @@ A numeric input element with increment and decrement buttons, and a scrub area.
- Sections:
- Usage guidelines
- Anatomy
- Examples
- Setting the input text imperatively
- API reference
- Root
- ScrubArea
Expand All @@ -1168,7 +1170,7 @@ A numeric input element with increment and decrement buttons, and a scrub area.
- Increment
- Exports:
- Number Field - Root
- Props: allowOutOfRange, allowWheelScrub, className, defaultValue, disabled, form, format, id, inputRef, largeStep, locale, max, min, name, onValueChange, onValueCommitted, readOnly, render, required, smallStep, snapOnStep, step, style, value
- Props: actionsRef, allowOutOfRange, allowWheelScrub, className, defaultValue, disabled, form, format, id, inputRef, largeStep, locale, max, min, name, onValueChange, onValueCommitted, readOnly, render, required, smallStep, snapOnStep, step, style, value
- Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-readonly, data-required, data-scrubbing, data-touched, data-valid
- Number Field - Input
- Props: aria-roledescription, className, render, style
Expand All @@ -1188,7 +1190,7 @@ A numeric input element with increment and decrement buttons, and a scrub area.
- Number Field - Increment
- Props: className, nativeButton, render, style
- Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-readonly, data-required, data-scrubbing, data-touched, data-valid
- Types: NumberField.Decrement.Props, NumberField.Decrement.State, NumberField.Group.Props, NumberField.Group.State, NumberField.Increment.Props, NumberField.Increment.State, NumberField.Input.Props, NumberField.Input.State, NumberField.Root.ChangeEventDetails, NumberField.Root.ChangeEventReason, NumberField.Root.CommitEventDetails, NumberField.Root.CommitEventReason, NumberField.Root.Props, NumberField.Root.State, NumberField.ScrubArea.Props, NumberField.ScrubArea.State, NumberField.ScrubAreaCursor.Props, NumberField.ScrubAreaCursor.State
- Types: NumberField.Decrement.Props, NumberField.Decrement.State, NumberField.Group.Props, NumberField.Group.State, NumberField.Increment.Props, NumberField.Increment.State, NumberField.Input.Props, NumberField.Input.State, NumberField.Root.Actions, NumberField.Root.ChangeEventDetails, NumberField.Root.ChangeEventReason, NumberField.Root.CommitEventDetails, NumberField.Root.CommitEventReason, NumberField.Root.Props, NumberField.Root.State, NumberField.ScrubArea.Props, NumberField.ScrubArea.State, NumberField.ScrubAreaCursor.Props, NumberField.ScrubAreaCursor.State

</details>

Expand Down
15 changes: 2 additions & 13 deletions packages/react/src/number-field/input/NumberFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import { useLabelableContext } from '../../internals/labelable-provider/Labelabl
import {
getNumberLocaleDetails,
isNumeralChar,
isValidInputString,
parseNumber,
ANY_MINUS_RE,
ANY_PLUS_RE,
ANY_MINUS_DETECT_RE,
ANY_PLUS_DETECT_RE,
FORMAT_CONTROL_DETECT_RE,
} from '../utils/parse';
import type { NumberFieldRootState } from '../root/NumberFieldRoot';
import { stateAttributesMapping } from '../utils/stateAttributesMapping';
Expand Down Expand Up @@ -256,18 +256,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
// Update the input text immediately and only fire onValueChange if the typed value is
// currently parseable into a number. This preserves good UX for IME
// composition/partial input while still providing live numeric updates when possible.
const allowedNonNumericKeys = getAllowedNonNumericKeys();
const isValidCharacterString = Array.from(targetValue).every(
(ch) =>
isNumeralChar(ch) ||
ANY_MINUS_DETECT_RE.test(ch) ||
allowedNonNumericKeys.has(ch) ||
// Bidi/format controls are stripped by `parseNumber`; don't let them reject the string
// (RTL locales insert them around exponent/currency signs, e.g. scientific notation).
FORMAT_CONTROL_DETECT_RE.test(ch),
);

if (!isValidCharacterString) {
if (!isValidInputString(targetValue, getAllowedNonNumericKeys())) {
return;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/number-field/root/NumberFieldRoot.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import { expectType } from '#test-utils';
import { NumberField } from '@base-ui/react/number-field';
import { REASONS } from '../../internals/reasons';
Expand Down Expand Up @@ -72,3 +73,13 @@ const numberFieldEventNarrowing = (
onValueCommitted={handleNumberFieldCommit}
/>
);

declare const numberFieldActionsRef: React.RefObject<NumberField.Root.Actions | null>;

const numberFieldImperativeActions = <NumberField.Root actionsRef={numberFieldActionsRef} />;

function assertNumberFieldActions(actions: NumberField.Root.Actions) {
actions.setInputValue('-');
// @ts-expect-error the raw text is a string, not a number
actions.setInputValue(5);
}
Loading
Loading