A number field: the user types in their locale's notation, steps with the keys or two buttons, and the form posts a machine number. The platform's <input type="number"> cannot be the visible control (it cannot format, it localises by the browser's language rather than the page's, and it is a text field to VoiceOver anyway), so NumberField.Input renders two inputs: a visible type="text" input the user edits, and right after it a visually hidden native type="number" input that carries name, min / max / step / required, the posted value, the form reset and the browser's own constraint messages. The library adds the Async React half: the typed text is urgent, a step is a transition, and each gesture is one commit with data-pending and a rollback if the save fails.
"use client";
import { startTransition, useState } from "react";
import { NumberField } from "@funstack/ui/number-field";
import { fakeSave } from "./fake-api.js";
// A cart line. Type a number (committed on blur or Enter), use the arrow keys, or press and hold
// a button: the save runs once per gesture in valueCommitAction (800 ms, data-pending on the Root,
// the Input and the pressed button). The parent adopts the quantity when the save succeeds; with
// failures on it keeps the old one, so the text returns to it.
export function NumberFieldQuantity() {
const [fail, setFail] = useState(false);
const [quantity, setQuantity] = useState<number | null>(1);
return (
<div className="demo-stack">
<label className="demo-field">
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
Make the save fail
</label>
<NumberField.Root
className="demo-number-field"
locale="en-US"
value={quantity}
min={1}
max={99}
valueCommitAction={async (next) => {
try {
await fakeSave(`quantity ${next}`, { fail });
startTransition(() => setQuantity(next));
} catch {
// The parent keeps its quantity: the field shows it again.
}
}}
>
{({ pending }) => (
<>
<NumberField.Label className="demo-number-label">Quantity</NumberField.Label>
<div className="demo-number-group">
<NumberField.Decrement className="demo-number-step">−</NumberField.Decrement>
<NumberField.Input className="demo-number-input" />
<NumberField.Increment className="demo-number-step">+</NumberField.Increment>
</div>
<span className="demo-number-status">
{pending ? "Saving…" : `In the cart: ${String(quantity)}`}
</span>
</>
)}
</NumberField.Root>
</div>
);
}/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.demo-number-label {
font-weight: 600;
color: var(--color-text);
}
.demo-number-group {
display: inline-grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-bg);
}
.demo-number-group:has(.demo-number-input:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.demo-number-input {
inline-size: 9ch;
min-inline-size: 6ch;
border: 0;
padding: 0.4rem 0.6rem;
background: transparent;
color: var(--color-text);
font: inherit;
font-variant-numeric: tabular-nums;
text-align: end;
}
.demo-number-input:focus {
outline: none;
}
.demo-number-input:user-invalid {
color: #dc2626;
}
.demo-number-input[data-pending] {
color: var(--color-text-secondary);
}
.demo-number-step {
inline-size: 2.25rem;
border: 0;
background: var(--color-bg-secondary, transparent);
color: var(--color-text);
font: inherit;
font-size: 1.1rem;
cursor: pointer;
}
.demo-number-step:active {
background: var(--color-border);
}
.demo-number-step:disabled {
opacity: 0.4;
cursor: default;
}
.demo-number-step[data-pending] {
cursor: progress;
}
.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
border-color: #dc2626;
}
.demo-number-status {
color: var(--color-text-secondary);
font-size: 0.875rem;
}| valueChangeAction | valueCommitAction | |
|---|---|---|
| Called | On every step: the parsed text at its commit, each key press, each tick of a held button, each wheel event, a form reset, text found at hydration, and the revert after a failed save (reason: "dismiss", uncontrolled). | Once per gesture: a typed text's blur or Enter, a held button's release, each key press, each wheel event, a form reset, text found at hydration. |
| Lane | A transition of its own: a controlled parent's setValue(next) in the synchronous prefix renders interruptibly. | The Root's commit transition, with useOptimistic. |
| Pending | data-pending while a step is in flight. Without a valueChangeAction a step starts no transition. | data-pending on the Root, the Input and the pressed button until the save settles. |
| Uncontrolled state | Not written. | Written when it resolves; reverts to the value before the gesture if it rejects. |
| Meant for | Adopting the value in a controlled parent, a live total, cheap synchronous work. | Saving: a Server Function, a fetch, requestSubmit(). |
-, 1, and 12 on the way to 123 are not the number the user means. The draft is internal, urgent state: typing never waits for a transition. A step (a key, a button) parses an open draft first.abc, 1,5 in English) reverts to the committed value's text with no dispatch. An empty field commits null."use client";
import { useState } from "react";
import { NumberField } from "@funstack/ui/number-field";
const locales = ["en-US", "de-DE", "fr-FR", "ar-EG"] as const;
type Locale = (typeof locales)[number];
// The buttons' built-in names are English; pass your own next to the locale.
const labels: Record<Locale, { increase: string; decrease: string }> = {
"en-US": { increase: "Increase", decrease: "Decrease" },
"de-DE": { increase: "Erhöhen", decrease: "Verringern" },
"fr-FR": { increase: "Augmenter", decrease: "Diminuer" },
"ar-EG": { increase: "زيادة", decrease: "إنقاص" },
};
// A price in euros and a percentage, formatted and parsed in the chosen locale. The text is the
// locale's (`12,50 €`, `€12.50`, `١٢٫٥٠ €`); the value and the posted form value are plain numbers.
// Try `1,5` in English (not a number: it reverts) and in German (1.5).
export function NumberFieldFormats() {
const [locale, setLocale] = useState<Locale>("de-DE");
const [price, setPrice] = useState<number | null>(12.5);
const { increase, decrease } = labels[locale];
return (
<div className="demo-stack">
<label className="demo-field">
Locale
<select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
{locales.map((l) => (
<option key={l}>{l}</option>
))}
</select>
</label>
<NumberField.Root
className="demo-number-field"
locale={locale}
format={{ style: "currency", currency: "EUR" }}
value={price}
min={0}
step={0.5}
valueChangeAction={(next) => {
setPrice(next);
}}
>
<NumberField.Label className="demo-number-label">Price</NumberField.Label>
<div className="demo-number-group">
<NumberField.Decrement className="demo-number-step" aria-label={decrease}>
−
</NumberField.Decrement>
<NumberField.Input className="demo-number-input" />
<NumberField.Increment className="demo-number-step" aria-label={increase}>
+
</NumberField.Increment>
</div>
</NumberField.Root>
<output className="demo-readout">value: {String(price)}</output>
<NumberField.Root
className="demo-number-field"
locale={locale}
format={{ style: "percent" }}
defaultValue={0.25}
min={0}
max={1}
stepOnWheel
>
<NumberField.Label className="demo-number-label">Discount (wheel on)</NumberField.Label>
<div className="demo-number-group">
<NumberField.Decrement className="demo-number-step" aria-label={decrease}>
−
</NumberField.Decrement>
<NumberField.Input className="demo-number-input" />
<NumberField.Increment className="demo-number-step" aria-label={increase}>
+
</NumberField.Increment>
</div>
</NumberField.Root>
</div>
);
}/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.demo-number-label {
font-weight: 600;
color: var(--color-text);
}
.demo-number-group {
display: inline-grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-bg);
}
.demo-number-group:has(.demo-number-input:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.demo-number-input {
inline-size: 9ch;
min-inline-size: 6ch;
border: 0;
padding: 0.4rem 0.6rem;
background: transparent;
color: var(--color-text);
font: inherit;
font-variant-numeric: tabular-nums;
text-align: end;
}
.demo-number-input:focus {
outline: none;
}
.demo-number-input:user-invalid {
color: #dc2626;
}
.demo-number-input[data-pending] {
color: var(--color-text-secondary);
}
.demo-number-step {
inline-size: 2.25rem;
border: 0;
background: var(--color-bg-secondary, transparent);
color: var(--color-text);
font: inherit;
font-size: 1.1rem;
cursor: pointer;
}
.demo-number-step:active {
background: var(--color-border);
}
.demo-number-step:disabled {
opacity: 0.4;
cursor: default;
}
.demo-number-step[data-pending] {
cursor: progress;
}
.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
border-color: #dc2626;
}
.demo-number-status {
color: var(--color-text-secondary);
font-size: 0.875rem;
}format is Intl.NumberFormatOptions: style "decimal" (default), "percent" (the value is a fraction, 0.25 shows 25%; default step 0.01), "currency" and "unit", plus fraction digits, grouping, sign display and numbering system.locale. The default is the runtime's locale (on the server and in the browser alike); a development warning fires when the page's lang disagrees and no locale was given.€, EUR, euros), a leading or trailing minus, U+2212 and accounting parentheses. Grouping is strict: a group separator must sit before the decimal separator and be followed by a whole group, so 1,5 in English is not 15 and 1.5 in German is not 15; the text reverts instead of silently becoming ten times larger.1.999 in a euro field commits 2 and shows 2,00 €, so what the user sees is what the form posts. Stepped values are rounded the same way (a step finer than the format cannot post a digit the text hides). Set maximumFractionDigits higher for more precision. notation: "compact" cannot be parsed back and logs a development warning.Like a native number input, a typed value is neither clamped to min / max nor snapped to step: it stays as typed, the hidden input reports rangeUnderflow, rangeOverflow or stepMismatch, the browser's localised message is mirrored onto the visible input, and the form does not submit until it is fixed. Stepping does clamp and snap, with the HTML stepUp() rules: an off-grid value snaps in the direction of the step, the step base is min (else 0; give min when the grid matters), and a button is disabled when its next step would change nothing (judged from the draft while one is open; an unreachable max stays unreachable: 0–5 by 2 stops at 4). To clamp typed values, do it in a controlled parent's valueChangeAction.
import { NumberField } from "@funstack/ui/number-field";
<NumberField.Root name="quantity" defaultValue={1} min={1} max={99} locale="en-US"
valueCommitAction={saveQuantity}> {/* div role=group, named by the Label */}
<NumberField.Label>Quantity</NumberField.Label> {/* label for= the visible input */}
<NumberField.Decrement>−</NumberField.Decrement> {/* button tabindex=-1 aria-label="Decrease" */}
<NumberField.Input /> {/* input type=text + hidden input type=number */}
<NumberField.Increment>+</NumberField.Increment> {/* button tabindex=-1 aria-label="Increase" */}
</NumberField.Root>NumberField.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | – | Controlled value; null is empty, undefined uncontrolled. NaN / Infinity are treated as empty (development warning). |
defaultValue | number | null | null | Initial value in uncontrolled mode; also the form-reset target (the latest value). |
valueChangeAction | ChangeAction<number | null, NumberFieldValueChangeDetails> | – | Runs in the Root's step transition on every step. Awaited; data-pending meanwhile. Adopt the value here in a controlled parent. Not the place for a save. |
valueCommitAction | ChangeAction<number | null, NumberFieldValueChangeDetails> | – | Runs in the Root's commit transition once per gesture. Awaited; the field keeps the gesture's value with data-pending until it settles. Uncontrolled, the value is committed when it resolves and reverts when it rejects. May be a Server Function. |
min / max | number | – | Native constraints on the hidden input, and the bounds of stepping. Home / End go to them when set. |
step | number | 1 (0.01 for style "percent") | Native step and the stepping grid from min (else 0). |
largeStep | number | 10 × step | PageUp / PageDown and Shift+Arrow. |
format | Intl.NumberFormatOptions | – | How the text is formatted and parsed: decimal, percent, currency, unit, fraction digits, grouping, sign display, numbering system. |
locale | string | readonly string[] | runtime locale | BCP 47 tag(s) for formatting and parsing. Pass the page's lang (development warning when it disagrees and this is absent). |
stepOnWheel | boolean | false | Step with the mouse wheel while the input is focused (a native non-passive listener; the page does not scroll then). |
name / form | string | – | On the hidden number input, which posts String(value) ("" when empty). name defaults to the Field's. |
disabled | boolean | false | Disables both inputs (not submitted) and the buttons. |
readOnly | boolean | false | Native readonly on both inputs; no stepping, buttons disabled. |
required | boolean | false | On the hidden input: an empty field is valueMissing. Or the Field's. |
announce | (value: number | null, state: NumberFieldState) => string | null | state.text | What is announced after a key press, a wheel event, a click activation and a hold's release (not per tick, not for typed text). null announces nothing. |
className | string | ((state: NumberFieldState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: NumberFieldState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: NumberFieldState) => ReactNode) | – | The parts. The function form receives the state. |
render | ReactElement | ((props, state: NumberFieldState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
NumberFieldState (every part but the buttons): { value, text, pending, disabled, readOnly, required }, where value is the shown value (the gesture's while one is open or in flight) and text the shown text (the draft while editing). Details: { reason, source, event }, reason being "activate", "platform" (a form reset, text found at hydration) or "dismiss" (the revert after a failed save, steps only), and source one of "input", "increment", "decrement", "keyboard", "wheel", or null on platform paths and the revert. event is the DOM event, and undefined for the repeats of a held button, a hold that ended at its bound, hydration and the revert. The Root owns role="group" and aria-labelledby (your tokens, then the Label's id, then a Field label's). There is no transition prop on any part; the dispatches carry the types fsui-number-field plus fsui-number-field-change (step) or fsui-number-field-commit (gesture) for your own boundaries.
NumberField.Input| Prop | Type | Default | Description |
|---|---|---|---|
hiddenInputRef | Ref<HTMLInputElement> | – | The hidden native <input type="number"> rendered after the visible input (ref is the visible one). |
inputMode / autoComplete / autoCorrect / spellCheck | string / boolean | "numeric" or "decimal", "off", "off", false | inputMode is "decimal" when fractions are allowed, else "numeric". Defaults your own prop replaces. |
className | string | ((state: NumberFieldState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: NumberFieldState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
render | ReactElement | ((props, state: NumberFieldState) => ReactNode) | – | Replace the default <input> (element form) or take over rendering (function form). |
The visible input owns id, type (text, or search / tel from a render element; a number element logs a warning), value, disabled, readOnly and its handlers. name, min, max, step, required and pattern are not props: constraints live on the hidden input, and a constraint on the visible one would make it invalid for a reason nobody reports. placeholder, aria-*, onFocus, onPaste, onCompositionStart / onCompositionEnd and the rest pass through.
NumberField.Increment, NumberField.Decrement| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Also disabled by the Root's disabled / readOnly, and when the next step would change nothing. |
aria-label | string | "Increase" / "Decrease" | The built-in English names. Your aria-label (or aria-labelledby) replaces them; pass the same text as aria-label to let a text label name the button. |
className | string | ((state: NumberFieldButtonState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: NumberFieldButtonState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: NumberFieldButtonState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: NumberFieldButtonState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
NumberFieldButtonState: { pending, disabled }, where pending is true while the latest step or commit came from this button and is in flight. The buttons own type="button" (on a <button> only), tabIndex={-1}, aria-controls and disabled.
NumberField.Label| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: NumberFieldState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: NumberFieldState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: NumberFieldState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: NumberFieldState) => ReactNode) | – | Replace the default <label> (element form) or take over rendering (function form). |
Must stay a <label>: it names the visible input through for and, through its id, the Root group.
| Input | Effect |
|---|---|
| ArrowUp / ArrowDown | ± step from the parsed draft or the shown value (empty counts as 0), snapped and clamped. |
| Shift+Arrow, PageUp / PageDown | ± largeStep. |
| Home / End | min / the largest step at or below max, only when that bound is set; otherwise the caret moves. Shift+Home / Shift+End always select text. |
| Enter | Commits the draft. Not prevented: inside a form with a submit button the implicit submission follows and posts the committed value. |
| Everything else | Text editing. Tab leaves the field and the blur commits. |
| A button press | One step at once, then a repeat after 400 ms and every 60 ms while held; one commit at the release. |
The wheel (stepOnWheel) | One step per wheel event while the input is focused; unfocused, the page scrolls. |
valueCommitAction a wheel flick is several commits; the latest wins.preventDefault()ed: focus stays where it was (the library never moves it), a touch opens no keyboard, and no text is selected. The buttons are not Tab stops (tabindex="-1", as the APG asks); a click with no pointer (assistive technology) steps once.inputMode="text" when negatives must be typed there.role="spinbutton". On a text input VoiceOver announces the wrong value for a spinbutton and its gestures rewrite the text, so the visible input is a plain textbox (as in React Aria and Base UI). The formatted text is its value, the group tells the user what the buttons change, and each step is announced.ariaNotify() on the input, with a visually hidden role="status" region in document.body as the fallback where it is missing. The default is the formatted text ("2", "1,50 €", "50%"); override with announce.aria-roledescription: it would replace "edit text", the cue that the field takes typing. Pass one through NumberField.Input if you want it.aria-hidden and unfocusable; its invalid event is always cancelled, so the browser reports on (and focuses) the visible input, which mirrors its validationMessage.| Attribute | On | Meaning |
|---|---|---|
data-pending | Root, Label, Input | A step (with a valueChangeAction) or commit is in flight. |
data-pending | Increment / Decrement | The latest step or commit came from this button and is in flight. |
data-disabled | every part | Disabled (the buttons also at their bound and when read-only). |
data-invalid | Root, Input | Inside a Field that shows an error. |
:user-invalid, :read-only, :disabled, :placeholder-shown | the visible input | The platform's own states. |
:active | a held button | No data-holding: the platform has it. |
.number-field { display: inline-grid; grid-template-columns: auto 1fr auto; }
.number-field .input { min-width: 6ch; text-align: end; font-variant-numeric: tabular-nums; }
.number-field .step { inline-size: 2.25rem; }
.number-field .step:disabled { opacity: .4; }
.number-field .input[data-pending] { background-image: var(--spinner); background-repeat: no-repeat; }
.number-field .step[data-pending] { cursor: progress; }
.number-field .input:user-invalid { border-color: var(--danger); }
.number-field[data-invalid] .step { border-color: var(--danger); }
.number-field:has(.input:focus-visible) { outline: 2px solid var(--accent); }The buttons' order is your markup's: an RTL page places Decrement first in source if it wants it at the inline start.
String(value) (quantity=12, price=12.5), "" when empty, nothing when disabled; the text is never posted.defaultValue is the reset target: the latest defaultValue uncontrolled, the mount-time value controlled); the library then dispatches one step and one commit with reason: "platform" and clears an open draft."platform" step and commit (or kept as the draft while the input is still focused). A form submitted before hydration posts the default value; the buttons do nothing yet. A plain <input type="number"> from a Server Component remains the JS-free number field.<form onChange> listener hears typed edits only."use client";
import { startTransition, useState } from "react";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import { NumberField } from "@funstack/ui/number-field";
import { fakeSave } from "./fake-api.js";
// A NumberField as a Field's control. `min`, `max` and `step` are native constraints on the hidden
// number input: a typed 7 is kept as typed and reported (`validity.stepMismatch`), not snapped.
// The browser's message is mirrored onto the visible input, so `:user-invalid`, the blocked
// submission and the focus move all land there. A + press snaps to the grid and clears it.
export function NumberFieldField() {
const [saved, setSaved] = useState<string | null>(null);
return (
<Form.Root
className="demo-form"
autoReset={false}
action={async (formData) => {
await fakeSave("the order");
startTransition(() => setSaved(String(formData.get("quantity"))));
}}
>
<Field.Root name="quantity" className="demo-fieldset">
<Field.Label className="demo-label">Quantity</Field.Label>
<NumberField.Root
className="demo-number-field"
locale="en-US"
min={6}
max={120}
step={6}
defaultValue={6}
>
<div className="demo-number-group">
<NumberField.Decrement className="demo-number-step">−</NumberField.Decrement>
<NumberField.Input className="demo-number-input" />
<NumberField.Increment className="demo-number-step">+</NumberField.Increment>
</div>
</NumberField.Root>
<Field.Description className="demo-description">
Sold in packs of six, up to 120.
</Field.Description>
<Field.Error className="demo-field-error">
{({ validity, messages }) =>
validity.stepMismatch ? "Enter a multiple of six." : messages.join(" ")
}
</Field.Error>
</Field.Root>
<div className="demo-row">
<Form.Submit className="demo-button demo-primary demo-submit">
{({ pending }) => (
<>
{pending && <span className="demo-spinner" aria-hidden />}
Order
</>
)}
</Form.Submit>
</div>
{saved !== null && <output className="demo-readout">Ordered {saved}.</output>}
</Form.Root>
);
}/* NumberField: the Root is the group (a grid of the two buttons around the input). The visible
input is a plain text field; the hidden number input after it is positioned by the library. */
.demo-number-field {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.demo-number-label {
font-weight: 600;
color: var(--color-text);
}
.demo-number-group {
display: inline-grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-bg);
}
.demo-number-group:has(.demo-number-input:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.demo-number-input {
inline-size: 9ch;
min-inline-size: 6ch;
border: 0;
padding: 0.4rem 0.6rem;
background: transparent;
color: var(--color-text);
font: inherit;
font-variant-numeric: tabular-nums;
text-align: end;
}
.demo-number-input:focus {
outline: none;
}
.demo-number-input:user-invalid {
color: #dc2626;
}
.demo-number-input[data-pending] {
color: var(--color-text-secondary);
}
.demo-number-step {
inline-size: 2.25rem;
border: 0;
background: var(--color-bg-secondary, transparent);
color: var(--color-text);
font: inherit;
font-size: 1.1rem;
cursor: pointer;
}
.demo-number-step:active {
background: var(--color-border);
}
.demo-number-step:disabled {
opacity: 0.4;
cursor: default;
}
.demo-number-step[data-pending] {
cursor: progress;
}
.demo-number-field[data-invalid] .demo-number-group,
.demo-number-group:has(:user-invalid) {
border-color: #dc2626;
}
.demo-number-status {
color: var(--color-text-secondary);
font-size: 0.875rem;
}Field.Label's for names it), its description and error, and the focus of a blocked submission. The Root's group is named by Field.Label too.Field.Error's function children sees validity.stepMismatch, rangeUnderflow, rangeOverflow and valueMissing, and the Field writes validate's message and server errors there, mirrored onto the visible input.name and required go to the hidden input, disabled to both inputs and the buttons. validate receives String(number) (the committed value, not the draft), so Number(value) is safe.validate and dismisses a server error); a rollback or a form reset re-runs validate only.data-pending, the form's submission is the Field's.// A Server Function as the save, straight from a Server Component:
<NumberField.Root defaultValue={line.quantity} min={1} locale="en-US" valueCommitAction={saveQuantity}>…</NumberField.Root>