Appearance
NumberInput
NumberInput is a quantity someone types, with two buttons and the arrow keys for stepping it. Reach for it when the digits are the point, a guest count or a price or a font size, and for Slider when the range matters more than the number. It is a field, so everything TextInput says about the caret, the selection and the clipboard is true here too.
Guests is controlled, capped at 8, and the line beside it is derived from the value. Tip is uncontrolled and steps by a half. Type a letter into either and watch what is not reported; then move focus away and watch the field tidy itself up.
tsx
/**
* Two number fields, and the two ways a value can be owned.
*
* **Guests is controlled.** The application holds the number and the
* line below is derived from it, so the total can only ever be the
* total of what the field shows. The button writes the cell directly.
*
* **Tip is uncontrolled**, and steps by a half, which is where the
* snapping shows: `step` is what the arrows, the two buttons and the
* typed text are all quantized to.
*
* A field holds text while it is being typed, because a half-written
* "1." or "-" is not a number yet. Nothing is reported until the text
* parses, and blurring normalises what is left there.
*/
export function Numbers(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const guests = internalState(2);
const total = guests.pipe(map(count => `${count} × ${PER_SEAT} = ${count * PER_SEAT}`));
return (
<column gap={14} padding={20} width={percent(100)} height={percent(100)}>
<NumberInput
label="Guests"
min={1}
max={8}
step={1}
required
value={guests}
onChange={next => (guests.value = next)}
/>
<row gap={12} y="center">
<button
label="Table for eight"
onClick={() => (guests.value = 8)}
padding={8}
borderRadius={6}
borderWidth={1}
borderColor="border"
backgroundColor="background"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
<text text="Table for eight" fontSize={12} color="text" />
</button>
<text text={total} fontSize={12} color="textMuted" />
</row>
<NumberInput label="Tip" min={0} max={20} step={0.5} defaultValue={2.5} />
</column>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
value | number | none | The value to show. Supplying it makes the field controlled. |
defaultValue | number | 0 | The value to start at, for a field that owns its own. Supplying both throws, naming the component. |
onChange | (value: number) => void | none | Called with a value already clamped to the range and snapped to the step. Text that is not a number reports nothing. |
min | number | unbounded | The bottom of the range. Left out, nothing clamps downwards. |
max | number | unbounded | The top of the range. Left out, nothing clamps upwards. |
step | number | 1 | What an arrow key and each button move by, and the grid a value is snapped to, counted from min. |
label | string | '' | Drawn above the field and used as its accessible name. |
error | string | '' | A non-empty string marks the field invalid, turns its border to the danger token, and draws this message under it. |
disabled | boolean | false | Refuses focus, ignores the arrows, and disables both buttons. |
required | boolean | false | Announced as required. It validates nothing on its own; the message is error's job. |
ref | UiNodeRef | none | Receives the node that is the field, rather than the column or the row around it. |
A range with both ends given is what makes step a grid: values are snapped to a multiple of the step counted from min, and rounded to the step's own number of decimal places, so a step of 0.5 cannot leave 2.5000000000000004 in the field. With an open end there is nothing to count from, and a value moves by whole steps from wherever it started instead.
Layout and modifiers
The shared layout props land on the component's root, and rootModifiers attaches to the element that is the field. The field inside grows to fill the row beside the two buttons and never goes below 80 pixels wide. There are no colour props: the field, the buttons and the message all read the theme.
Controlled and uncontrolled
tsx
<NumberInput label="Guests" value={guests} onChange={next => (guests.value = next)} />
<NumberInput label="Guests" defaultValue={2} />Which form applies is decided once, when the component is built, from whether value was supplied; supplying both throws. A controlled field handed no onChange does not move until the application moves it, and the value it is written is shown as it is: min and max bound what a person can produce with the keyboard, the buttons or the text, not what the application can set. A value written from outside the range is shown, and the first step from there lands back inside it.
The field holds text, the application holds a number
The two are not the same thing while someone is typing, and the component keeps them apart on purpose. A half-written - or 1. is not a number yet, so:
- Every keystroke goes into the field's text.
- A number is reported only when the text parses and is not blank, and it is clamped and snapped before it goes out.
- Text that does not parse leaves the value alone, so a stray letter costs you nothing but the letter.
- When the field loses focus it commits what it is holding: the text becomes the value's own spelling again, which is what stops a field being left showing
2.50or12abc. Text that is not a number at all commits as0, clamped into the range.
Keyboard
The field is the tab stop. The two buttons are deliberately not focusable: their job is the arrows' job, and nobody should have to walk past two buttons to leave a number field.
| Key | What it does |
|---|---|
| Up | One step up, clamped to max |
| Down | One step down, clamped to min |
| Everything else | The field's own: the caret, selection, deletion and undo of a text field |
Up and Down are taken by the component, so they step the value rather than moving the caret between lines. Every other key in TextInput's table behaves here exactly as it does there, including Home, End, the word modifier, and select-all.
Semantics
The field is the node in the semantics tree; the column and the row are not. It emits:
role:spinbutton.label: thelabelprop, which is the string drawn above the field as well.valueNow,valueMin,valueMax: the number, and the range. With nominormaxgiven, the range is reported as negative and positive infinity.valueText: the text in the field, which is why it can be"2.50"whilevalueNowis2.5. That is the truth about what a person is looking at, and it is the difference between the two that a blur resolves.states:invalidwhileerroris a non-empty string, andrequiredwhilerequiredis set.
The two step buttons carry role: 'button' and the names Increase and Decrease, so they are announced and clickable while staying out of the tab order. The message under the field carries its own text into the tree.
Next
Slider is the same number without the digits, and TextInput is the field this one is built from.