Skip to content

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

PropTypeDefaultWhat it does
valuenumbernoneThe value to show. Supplying it makes the field controlled.
defaultValuenumber0The value to start at, for a field that owns its own. Supplying both throws, naming the component.
onChange(value: number) => voidnoneCalled with a value already clamped to the range and snapped to the step. Text that is not a number reports nothing.
minnumberunboundedThe bottom of the range. Left out, nothing clamps downwards.
maxnumberunboundedThe top of the range. Left out, nothing clamps upwards.
stepnumber1What an arrow key and each button move by, and the grid a value is snapped to, counted from min.
labelstring''Drawn above the field and used as its accessible name.
errorstring''A non-empty string marks the field invalid, turns its border to the danger token, and draws this message under it.
disabledbooleanfalseRefuses focus, ignores the arrows, and disables both buttons.
requiredbooleanfalseAnnounced as required. It validates nothing on its own; the message is error's job.
refUiNodeRefnoneReceives 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.50 or 12abc. Text that is not a number at all commits as 0, 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.

KeyWhat it does
UpOne step up, clamped to max
DownOne step down, clamped to min
Everything elseThe 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: the label prop, which is the string drawn above the field as well.
  • valueNow, valueMin, valueMax: the number, and the range. With no min or max given, the range is reported as negative and positive infinity.
  • valueText: the text in the field, which is why it can be "2.50" while valueNow is 2.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: invalid while error is a non-empty string, and required while required is 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.