Skip to content

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.

React
tsx
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' }} />;
dart
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

PropTypeDefaultDescription
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
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
valuenumber | nullThe number. Use with onValueChange for a controlled field
defaultValuenumberThe initial number, for an uncontrolled field
onValueChange(value: number | null) => voidCalled on every change — typing, stepping, the wheel
onValueCommitted(value: number | null) => voidCalled when the value settles: on blur after typing, on pointer release, and together with onValueChange for the keyboard
minnumberThe bottom of the range. Stepping stops here
maxnumberThe top of the range
stepnumber | 'any'1How far one step goes. any turns step validation off
largeStepnumber10The step taken while Shift is held
smallStepnumber0.1The step taken while Alt is held
snapOnStepbooleanfalseWhether stepping snaps to multiples of the step
allowWheelScrubbooleanfalseWhether 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
formatIntl.NumberFormatOptionsHow the number is written — currency, percent, decimal places. Passed straight to Intl.NumberFormat, so the field shows $1,240.00 and still reports 1240
localeIntl.LocalesArgumentWhich 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
incrementLabelstring'Increase'Accessible name of the increment button
decrementLabelstring'Decrease'Accessible name of the decrement button
labelReactNodeLabel above the control, wired to it by Base UI’s Field. There is no floating variant on purpose: floating labels need a transform
descriptionReactNodeHelper text below the control
errorReactNodeError message below the control. Its presence also turns the field invalid, which re-points the whole slot family at danger
invalidbooleanForces the invalid state without a message. Defaults to !!error
startIconReactNodeContent placed before the number — a currency mark, a unit, an icon
endIconReactNodeContent placed after the number, before the steppers
fullWidthsharedbooleanfalseStretches to the width of the container
readOnlysharedbooleanfalseThe number is shown but cannot be changed, and the steppers are not drawn
disabledsharedbooleanfalseUnavailable
hotKeysRecord<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
PropTypeDefaultDescription
value * double?The number. Use with onValueChange for a controlled field
onChangedValueChanged<double?>?Called on every change — a keystroke, a step, the wheel. It reports what has been typed, not what it will settle to
onCommittedValueChanged<double?>?Called when the value settles: on blur after typing, on pointer release, and together with onValueChange for the keyboard
mindouble?The bottom of the range. Stepping stops here
maxdouble?The top of the range
stepdouble1How far one step goes. any turns step validation off
largeStepdouble10The step taken while Shift is held
smallStepdouble0.1The step taken while Alt is held
snapOnStepboolfalseWhether stepping snaps to multiples of the step
allowWheelScrubboolfalseWhether 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
formatString Function(double value)?How a settled value is written. There is no Intl.NumberFormat in the Dart SDK, so the format is a function
parsedouble? Function(String text)?How typed text is read back. Left out, everything but digits, a sign and a decimal point is thrown away
steppersPlNumberFieldSteppersPlNumberFieldSteppers.endWhere 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
incrementLabelString'Increase'Accessible name of the increment button
decrementLabelString'Decrease'Accessible name of the decrement button
variantsharedPlassVariantPlassVariant.glassWhat the shell is made of, identical to PlTextField to the pixel — solid is the well cut into the sheet, not a tinted pane
sizesharedPlassSizePlassSize.mdControl height and type scale — the same ladder as every other field in the form
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint0Drop shadow depth. 0 means no shadow at all
labelWidget?Label above the control. There is no floating variant on purpose: a floating label is text that moves
descriptionWidget?Helper text below the control
errorWidget?Error message below the control. Its presence also turns the field invalid, which re-points the whole slot family at danger
invalidbool?Forces the invalid state without a message. Defaults to !!error
placeholderString?Shown while the field is empty
startIconWidget?Content placed before the number — a currency mark, a unit, an icon
endIconWidget?Content placed after the number, before the steppers
fullWidthsharedboolfalseStretches to the width of the container
readOnlysharedboolfalseThe number is shown but cannot be changed, and the steppers are not drawn
disabledsharedboolfalseUnavailable
semanticLabelString?The name a screen reader gives the field
focusNodeFocusNode?Drive focus from outside
autofocusboolfalseTakes focus as it is inserted into the tree
hotKeysPlassHotKeys?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.

React

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.

React

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.

React

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.

React

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.

React

size

React

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 id from the caller.
  • Both steppers carry an accessible name already; incrementLabel and decrementLabel are what change them.
  • A stepper that has run into the end of the range is genuinely disabled, not just dimmed.
  • allowWheelScrub is 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.00 rather than 1240. What is drawn is what is read.
  • Both steppers carry a name already; incrementLabel and decrementLabel are 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.
  • allowWheelScrub is 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 id to wire and nothing to forget to wire.

Differences from the React build

ReactFlutterWhy
value / defaultValue / onValueChangevalue / onChangedFlutter's own controls are controlled, and its name for the callback.
onValueCommittedonCommittedSame idea, shorter name, and it is where the clamp happens.
format, an Intl.NumberFormatOptionsformat and parse, two functionsThere 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.
localeIt belongs to the formatter the app passes in, which already knows which locale it is writing.
a value of number | nulldouble?Dart's floating-point type. An int field is step: 1 with a format that writes no decimals.
the hidden input, name, requiredThere is no native form submission to be part of.
idNothing points at anything by id here; the label and the messages are part of the component.
className, style, native attributesThere is no class list and no style attribute to pass through.

Released under the MIT License