PlNumberField
A field that only holds a number. The shell is a PlTextField's to the pixel; what is added on top is a real numeric control, arrow keys, steppers, clamping and locale-aware formatting.
import { PlNumberField } from 'plass-ui';
<PlNumberField label="Quantity" min={1} max={12} defaultValue={2} />;
<PlNumberField label="Budget" locale="en-US" format={{ style: 'currency', currency: 'USD' }} />;import 'package:plass_ui/plass_ui.dart';
PlNumberField(
label: const Text('Quantity'),
min: 1,
max: 12,
value: quantity,
onChanged: (double? next) => setState(() => quantity = next),
);
PlNumberField(
label: const Text('Budget'),
value: budget,
format: (double value) => '\$${value.toStringAsFixed(2)}',
onChanged: (double? next) => setState(() => budget = next),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the shell is made of, identical to PlTextField to the pixel — solid is the well cut into the sheet, not a tinted pane |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Control height and type scale — the same ladder as every other field in the form |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| value | number | null | — | The number. Use with onValueChange for a controlled field |
| defaultValue | number | — | The initial number, for an uncontrolled field |
| onValueChange | (value: number | null) => void | — | Called on every change — typing, stepping, the wheel |
| onValueCommitted | (value: number | null) => void | — | Called when the value settles: on blur after typing, on pointer release, and together with onValueChange for the keyboard |
| min | number | — | The bottom of the range. Stepping stops here |
| max | number | — | The top of the range |
| step | number | 'any' | 1 | How far one step goes. any turns step validation off |
| largeStep | number | 10 | The step taken while Shift is held |
| smallStep | number | 0.1 | The step taken while Alt is held |
| snapOnStep | boolean | false | Whether stepping snaps to multiples of the step |
| allowWheelScrub | boolean | false | Whether the wheel changes the value while focused and hovered. Off by default: a page that scrolls under the pointer and a field that changes under it are the same gesture, and only one of them was meant |
| format | Intl.NumberFormatOptions | — | How the number is written — currency, percent, decimal places. Passed straight to Intl.NumberFormat, so the field shows $1,240.00 and still reports 1240 |
| locale | Intl.LocalesArgument | — | Which locale the number is written and parsed in. Defaults to the runtime’s |
| steppers | 'end' | 'split' | 'none' | 'end' | Where the steppers sit. There is deliberately no stacked pair of half-height chevrons: at xs each arrow would be under three pixels tall, and a target that small is one nobody hits |
| incrementLabel | string | 'Increase' | Accessible name of the increment button |
| decrementLabel | string | 'Decrease' | Accessible name of the decrement button |
| label | ReactNode | — | Label above the control, wired to it by Base UI’s Field. There is no floating variant on purpose: floating labels need a transform |
| description | ReactNode | — | Helper text below the control |
| error | ReactNode | — | Error message below the control. Its presence also turns the field invalid, which re-points the whole slot family at danger |
| invalid | boolean | — | Forces the invalid state without a message. Defaults to !!error |
| startIcon | ReactNode | — | Content placed before the number — a currency mark, a unit, an icon |
| endIcon | ReactNode | — | Content placed after the number, before the steppers |
| fullWidthshared | boolean | false | Stretches to the width of the container |
| readOnlyshared | boolean | false | The number is shown but cannot be changed, and the steppers are not drawn |
| disabledshared | boolean | false | Unavailable |
| hotKeys | Record<string, () => void> | — | Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed** |
| classNames | { label?, control?, description?, error?: string } | — | Classes on the parts a className does not reach. control is the part a reader acts on |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | double? | — | The number. Use with onValueChange for a controlled field |
| onChanged | ValueChanged<double?>? | — | Called on every change — a keystroke, a step, the wheel. It reports what has been typed, not what it will settle to |
| onCommitted | ValueChanged<double?>? | — | Called when the value settles: on blur after typing, on pointer release, and together with onValueChange for the keyboard |
| min | double? | — | The bottom of the range. Stepping stops here |
| max | double? | — | The top of the range |
| step | double | 1 | How far one step goes. any turns step validation off |
| largeStep | double | 10 | The step taken while Shift is held |
| smallStep | double | 0.1 | The step taken while Alt is held |
| snapOnStep | bool | false | Whether stepping snaps to multiples of the step |
| allowWheelScrub | bool | false | Whether the wheel changes the value while focused and hovered. Off by default: a page that scrolls under the pointer and a field that changes under it are the same gesture, and only one of them was meant |
| format | String Function(double value)? | — | How a settled value is written. There is no Intl.NumberFormat in the Dart SDK, so the format is a function |
| parse | double? Function(String text)? | — | How typed text is read back. Left out, everything but digits, a sign and a decimal point is thrown away |
| steppers | PlNumberFieldSteppers | PlNumberFieldSteppers.end | Where the steppers sit. There is deliberately no stacked pair of half-height chevrons: at xs each arrow would be under three pixels tall, and a target that small is one nobody hits |
| incrementLabel | String | 'Increase' | Accessible name of the increment button |
| decrementLabel | String | 'Decrease' | Accessible name of the decrement button |
| variantshared | PlassVariant | PlassVariant.glass | What the shell is made of, identical to PlTextField to the pixel — solid is the well cut into the sheet, not a tinted pane |
| sizeshared | PlassSize | PlassSize.md | Control height and type scale — the same ladder as every other field in the form |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 means no shadow at all |
| label | Widget? | — | Label above the control. There is no floating variant on purpose: a floating label is text that moves |
| description | Widget? | — | Helper text below the control |
| error | Widget? | — | Error message below the control. Its presence also turns the field invalid, which re-points the whole slot family at danger |
| invalid | bool? | — | Forces the invalid state without a message. Defaults to !!error |
| placeholder | String? | — | Shown while the field is empty |
| startIcon | Widget? | — | Content placed before the number — a currency mark, a unit, an icon |
| endIcon | Widget? | — | Content placed after the number, before the steppers |
| fullWidthshared | bool | false | Stretches to the width of the container |
| readOnlyshared | bool | false | The number is shown but cannot be changed, and the steppers are not drawn |
| disabledshared | bool | false | Unavailable |
| semanticLabel | String? | — | The name a screen reader gives the field |
| focusNode | FocusNode? | — | Drive focus from outside |
| autofocus | bool | false | Takes focus as it is inserted into the tree |
| hotKeys | PlassHotKeys? | — | Chords this control answers to, spelled the way PlHotKeys draws them — { 'Mod+Enter': save, Escape: cancel }. A chord that matches is **consumed** |
Every native <div> attribute passes straight through, onto the field's wrapper. color, defaultValue and children are excluded from the pass-through because all three are Plass props here.
A className lands on the stack that holds the label, the control and the two lines under it. classNames reaches the four parts inside it: label, control (the shell, steppers included) description and error.
Controlled, like every other input in the package: it is handed a value and reports what the value should become. There is no defaultValue, and value is a double?. null is an empty box.
There are two callbacks rather than one, and the difference matters here more than anywhere else in the library. onChanged fires on every keystroke with what has been typed; onCommitted fires when the field settles, with what it settled to. 5 on the way to 50 is half-finished rather than out of a range that starts at ten, so the clamp waits for the field to settle.
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Examples
steppers
end puts both buttons at the trailing edge, the way a spinner has always looked. split puts the minus at the start and the plus at the end with the number between them, for a quantity that is nudged rather than typed. none drops them, and the field is still a number field, the arrow keys, the clamping and the formatting all stay.
There is deliberately no stacked pair of half-height chevrons. At xs each arrow would be under three pixels tall, and a target that small is a target nobody hits.
format
Passed straight to Intl.NumberFormat, so the field shows $1,240.00 or 18.5% while value stays a plain number. What was typed is parsed back against the same locale, which is what makes a comma mean a decimal point where it should.
Two functions rather than one options object: format writes a settled value and parse reads typed text back. There is no Intl.NumberFormat in the Dart SDK and this package has no dependencies, so a locale-aware field is one an app builds out of its own formatter, which it already has, because the rest of its screens need one too.
Left out, format writes a whole number with no decimal point and parse throws away everything but digits, a sign and a decimal point. That default pair is what makes $1,240.50 typeable into a field showing currency without either function being written.
step, largeStep and smallStep
The arrow keys and the steppers both move by step, with Shift taking largeStep and Alt taking smallStep, the modifiers count for a pressed stepper as well as a pressed key. snapOnStep makes a step land on a multiple rather than move by one.
Page Up and Page Down take largeStep too, and Home and End go to min and max when there are any. A stepper held down repeats after a short pause; a stepper pressed and let go is worth exactly one step, the way every other button in the library is.
variant
The shell is a PlTextField's, to the pixel. A form where the quantity box is a different height or radius from the boxes around it is a form that looks assembled rather than designed, so solid is the well cut into the sheet here too, not a tinted pane.
States
readOnly keeps the number readable and takes the steppers away; there is nothing to press on a value that cannot change. error also turns the field invalid, which re-points the whole slot family at danger, so the edge, the ring, the caret and the message all turn over together.
size
Accessibility
- Base UI's NumberField owns the hard parts: parsing what was typed against the locale, clamping to
min/max, the press-and-hold repeat on the steppers, and the hidden input that submits with a form. - The label, the description and the error are wired to the control by Base UI's Field, so none of them needs an
idfrom the caller. - Both steppers carry an accessible name already;
incrementLabelanddecrementLabelare what change them. - A stepper that has run into the end of the range is genuinely
disabled, not just dimmed. allowWheelScrubis off by default. A page that scrolls under the pointer and a field that changes under it are the same gesture, and only one of them was meant.
- The field is announced as a text field holding what it shows, so a screen reader reads
$1,240.00rather than1240. What is drawn is what is read. - Both steppers carry a name already;
incrementLabelanddecrementLabelare what change them. Each is its own focus stop, after the number. - A stepper that has run into the end of the range is announced as unavailable, not merely dimmed.
- The arrow keys are bound inside the field, closer to the editor than an app's own text-editing shortcuts, which is what keeps the up arrow moving the number rather than the caret.
allowWheelScrubis off by default, and even on it wants the field focused and the pointer over it. A page that scrolls under the pointer and a field that changes under it are the same gesture, and only one of them was meant.- The label, the description and the error are part of the component, so there is no
idto wire and nothing to forget to wire.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
value / defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
onValueCommitted | onCommitted | Same idea, shorter name, and it is where the clamp happens. |
format, an Intl.NumberFormatOptions | format and parse, two functions | There is no Intl.NumberFormat in the Dart SDK and this package has no dependencies. A field that formats without parsing cannot be typed into, so both halves are the caller's. |
locale | — | It belongs to the formatter the app passes in, which already knows which locale it is writing. |
a value of number | null | double? | Dart's floating-point type. An int field is step: 1 with a format that writes no decimals. |
the hidden input, name, required | — | There is no native form submission to be part of. |
id | — | Nothing points at anything by id here; the label and the messages are part of the component. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |