Components

Number field

A single numeric value, typed with arithmetic and units or stepped with the keys, committed when people are done.

Markdown

A number field takes a single numeric value, typed, stepped or dragged. It is the workhorse of inspectors, forms and settings, and it should feel like nothing at all: whatever people type, paste, step or drag should simply become the value they meant.

An inspector

Drag across X, W or the word Rotation — with Shift or Alt for larger or finer steps, Escape to take it back — or hold an arrow key: the shape follows, and the whole is one change to undo. Rotation goes round: type 370, or 0.25turn. Type 1in into X, or (148-60)/2 into W.

Position
Size
0 changes
Code
"use client";

import { NumberField } from "@publictheta/ui/components/number-field/number-field.tsx";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { angle, length } from "@publictheta/ui/primitives/units.ts";
import { useId, useRef, useState } from "react";
import type { ReactNode } from "react";

/** The artboard, in millimetres: A6 landscape. */
const ARTBOARD = { width: 148, height: 105 };

interface Shape {
  readonly x: number;
  readonly y: number;
  readonly width: number;
  readonly height: number;
  readonly rotation: number;
  readonly opacity: number;
}

const INITIAL: Shape = { x: 24, y: 18, width: 60, height: 40, rotation: 0, opacity: 0.85 };

function Row({ label, children }: { label: string; children: (id: string) => ReactNode }) {
  const id = useId();
  return (
    <div className="grid grid-cols-[4.5rem_1fr] items-center gap-2">
      <label htmlFor={id} className="text-xs text-fg-muted">
        {label}
      </label>
      {children(id)}
    </div>
  );
}

function Pair({ label, children }: { label: string; children: ReactNode }) {
  return (
    <div role="group" aria-label={label} className="grid grid-cols-[4.5rem_1fr_1fr] items-center gap-2">
      <span className="text-xs text-fg-muted">{label}</span>
      {children}
    </div>
  );
}

export default function Inspector() {
  const [shape, setShape] = useState(INITIAL);
  // One entry for each thing done — a value typed, a key held, a label dragged — however many values it passed through.
  const [history, setHistory] = useState<readonly Shape[]>([]);
  const settled = useRef(INITIAL);

  const field = (key: keyof Shape) => ({
    value: shape[key],
    onValueChange: (value: number | null) => setShape((current) => ({ ...current, [key]: value ?? 0 })),
    onValueCommit: (value: number | null) => {
      const before = settled.current;
      settled.current = { ...before, [key]: value ?? 0 };
      setHistory((entries) => [...entries, before]);
    },
  });
  const undo = () => {
    const before = history.at(-1);
    if (before === undefined) return;
    settled.current = before;
    setShape(before);
    setHistory(history.slice(0, -1));
  };

  const mm = { units: length, unit: "mm", precision: 2, locale: "en-US" } as const;
  const { x, y, width, height, rotation, opacity } = shape;

  return (
    <div className="flex w-full flex-col items-center gap-6 sm:flex-row sm:items-start">
      <svg
        role="img"
        aria-label="Artboard"
        viewBox={`0 0 ${ARTBOARD.width} ${ARTBOARD.height}`}
        className="w-full max-w-72 rounded-md bg-surface shadow ring-1 ring-border-muted"
      >
        <rect
          x={x}
          y={y}
          width={width}
          height={height}
          rx={2}
          fill="var(--ui-accent)"
          opacity={opacity}
          transform={`rotate(${rotation} ${x + width / 2} ${y + height / 2})`}
        />
      </svg>
      <div className="flex w-full max-w-80 flex-col gap-2">
        <Pair label="Position">
          <NumberField aria-label="X" scrubHandle="X" {...mm} {...field("x")} />
          <NumberField aria-label="Y" scrubHandle="Y" {...mm} {...field("y")} />
        </Pair>
        <Pair label="Size">
          <NumberField aria-label="Width" scrubHandle="W" {...mm} minimum={0} {...field("width")} />
          <NumberField aria-label="Height" scrubHandle="H" {...mm} minimum={0} {...field("height")} />
        </Pair>
        <Row label="Rotation">
          {(id) => (
            <NumberField
              id={id}
              scrub
              locale="en-US"
              units={angle}
              unit="deg"
              precision={1}
              minimum={0}
              maximum={360}
              wrap
              {...field("rotation")}
            />
          )}
        </Row>
        <Row label="Opacity">
          {(id) => (
            <NumberField
              id={id}
              scrub
              locale="en-US"
              kind="percent"
              precision={2}
              step={1}
              minimum={0}
              maximum={1}
              {...field("opacity")}
            />
          )}
        </Row>
        <div className="mt-2 flex items-center justify-between gap-2">
          <output aria-live="polite" className="text-xs text-fg-muted">
            {history.length === 1 ? "1 change" : `${history.length} changes`}
          </output>
          <Button focusableWhenDisabled disabled={history.length === 0} onClick={undo}>
            Undo
          </Button>
        </div>
      </div>
    </div>
  );
}

A price, in Japanese

Currency, quantity and a percentage, read and shown in Japanese; full-width digits from an input method are numbers too. Hold a quantity button down; type 200 into the quantity and it is limited, and says so.

合計¥3,240
Code
"use client";

import { NumberField } from "@publictheta/ui/components/number-field/number-field.tsx";
import type { NumberFieldMessages } from "@publictheta/ui/components/number-field/number-field.tsx";
import { useId, useState } from "react";

/** What the fields say, in Japanese. */
const MESSAGES: NumberFieldMessages = {
  decrease: "減らす",
  increase: "増やす",
  limited: (limit) => `${limit} までに制限しました。`,
  unreadable: { invalid: "数値として読めません。", empty: "値を入力してください。" },
};

export default function Price() {
  const ids = { price: useId(), quantity: useId(), discount: useId() };
  const [price, setPrice] = useState<number | null>(1200);
  const [quantity, setQuantity] = useState<number | null>(3);
  const [discount, setDiscount] = useState<number | null>(0.1);
  const total = (price ?? 0) * (quantity ?? 0) * (1 - (discount ?? 0));
  const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });

  return (
    <div
      lang="ja-JP"
      className="grid w-full max-w-sm grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm"
    >
      <label htmlFor={ids.price} className="text-fg-muted">
        単価
      </label>
      <NumberField
        id={ids.price}
        locale="ja-JP"
        kind="currency"
        currency="JPY"
        precision={0}
        minimum={0}
        step={100}
        messages={MESSAGES}
        value={price}
        onValueChange={setPrice}
      />
      <label htmlFor={ids.quantity} className="text-fg-muted">
        数量
      </label>
      <NumberField
        id={ids.quantity}
        locale="ja-JP"
        precision={0}
        minimum={1}
        maximum={99}
        steppers="always"
        messages={MESSAGES}
        value={quantity}
        onValueChange={setQuantity}
      />
      <label htmlFor={ids.discount} className="text-fg-muted">
        割引
      </label>
      <NumberField
        id={ids.discount}
        locale="ja-JP"
        kind="percent"
        precision={2}
        step={0.01}
        minimum={0}
        maximum={1}
        messages={MESSAGES}
        value={discount}
        onValueChange={setDiscount}
      />
      <span className="text-fg-muted">合計</span>
      <output className="text-base font-semibold text-fg-strong tabular-nums">
        {yen.format(Math.round(total))}
      </output>
    </div>
  );
}

When to use

Use a number field when the value is exact and people know it, or work it out. When the value is approximate and its position within a range matters more than its exact figure, a slider shows that directly; many interfaces pair the two.

Best practices

Edit as every typed field does. Typing is left alone, the value is read at commit, Escape takes an edit back, and an input method is allowed to finish; see Editing values.

Show the unit, and label the field. A field without a unit leaves people to guess what it measures; a field without a label leaves assistive technology nothing to announce.

Tell a change from its end. While an arrow key is held, a label is dragged or a button is held down, the value passes through many values: show each as it is reached, so that what the number controls follows. Report the whole as one change when it ends — the key let go, the pointer released — so that it is one entry to undo and expensive work runs once. A commit of what was typed is a change and its end at once. Nothing ends that left the value where it was.

Let the label be dragged, where values are set by eye. In an inspector, dragging sideways across a field's label — or a letter or icon at the start of the field — changes the value a step at a time, as the arrow keys would; see Scrubbing. A click on the label still gives the field focus. In a form, where a value is known and typed once, leave the label alone.

Let a cycle go round. An angle or an hour has no ends: a step past the maximum arrives from the minimum, and a typed value beyond the range is read round it — 370° is 10°. The maximum is the minimum again and is shown as the minimum: 360° is 0°. Nothing was limited, so nothing is said or marked. A range is a cycle only when it has both ends.

Say in words what the field shows by a mark. When a typed value was held within the range, say so, with the limit as the field shows it: "Limited to 100 mm." When text could not be read, say why, and make that the field's description for as long as it is invalid. Say nothing while people type, and nothing about a value that was only rounded or stepped to the end of its range. Where whatever contains the field already marks it invalid and gives the reason, the field leaves the words to it.

Platform considerations

Keyboard. Up and Down Arrow step; Shift makes a step ten times larger and Option or Alt ten times smaller; Page Up and Page Down step by ten. A key held down repeats.

Pointer. A press that brings focus selects the value, so that typing replaces it. A label or handle that can be dragged shows a sideways resize cursor.

Touch. The field is at least 44 CSS pixels tall. It offers a number pad only where a plain number of zero or more is all there is to type — with a decimal point unless the value is whole. A phone's number pads have no minus sign, no letters and no percent sign, so a field that takes a negative number, a unit or a percentage offers the full keyboard, which has them. A number pad may have no key to finish with either: leaving the field commits, as it does everywhere.

A phone has no arrow keys. Where stepping matters, offer a button on either side of the field to decrease and increase, each at least 44 CSS pixels square. A press steps once; held down, a button steps again after a moment and then faster and faster, until it is let go, the finger slides off it, or the value reaches the end of its range — where that button is no longer offered. A press on a button leaves focus where it was, so no keyboard appears or disappears. With a mouse and a keyboard at hand the buttons are clutter: show them there only where they are the point, as in a quantity.

A label is dragged with a finger as with a mouse, and only sideways: a drag up or down that begins on it still scrolls the page.

Assistive technology. The field is a spin button with its value, its range, and the value with its unit as read aloud — while the text is that value. While another is being typed, the field states none. The buttons that step are named — "Decrease", "Increase", in the language of the interface — say which field they control, and are left out of the Tab order, since the field's own arrow keys step; activated without a pointer, each steps once. A handle inside the field is decoration: the field keeps its one name. What the field says in words is said politely, once, when it becomes true.

See also Numbers and Scrubbing.

Behaviour

Semantics

Is a spin button with its value, range, and the value as read aloud
const screen = await render(<Width minimum={0} maximum={1000} units={length} unit="mm" />);
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "120");
await expect.element(field(screen)).toHaveAttribute("aria-valuemin", "0");
await expect.element(field(screen)).toHaveAttribute("aria-valuemax", "1000");
await expect.element(field(screen)).toHaveAttribute("aria-valuetext", "120 mm");
While another value is being typed, the field does not state the one it holds
const screen = await render(<Width />);
await type(screen, "64");
await expect.element(field(screen)).not.toHaveAttribute("aria-valuenow");
await expect.element(field(screen)).not.toHaveAttribute("aria-valuetext");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "64");
Has no accessibility violations, valid or invalid
const screen = await render(<Width />);
await expectAccessible(screen.container);
await type(screen, "abc");
await userEvent.keyboard("{Enter}");
await expectAccessible(screen.container);
Has no accessibility violations with a label that scrubs, a handle, and buttons that step
const screen = await render(<Labelled scrubHandle="W" steppers="always" maximum={120} />);
await expectAccessible(screen.container);

Typing and committing

What is typed is left as typed until it is committed
const onValueChange = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} />);
await type(screen, "1234.5");
await expect.element(field(screen)).toHaveValue("1234.5");
expect(onValueChange).not.toHaveBeenCalled();
Enter commits, and the value is shown in the locale's format
const screen = await render(<Width />);
await type(screen, "1234.5");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("1,234.5");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "1234.5");
Leaving the field commits
const screen = await render(<Width />);
await type(screen, "64");
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "64");
Arithmetic commits as its result
const screen = await render(<Width />);
await type(screen, "(40-8)*2");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("64");
Text pasted from elsewhere — another locale, full-width digits — commits as a number
const screen = await render(<Width />);
await type(screen, "123.5");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("123.5");
await type(screen, "1.234,5");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("1,234.5");
A value in another unit is converted to the field's
const screen = await render(<Width units={length} unit="mm" />);
await type(screen, "1in");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("25.4 mm");
A value beyond the range commits as the nearest end of it
const screen = await render(<Width minimum={0} maximum={500} />);
await type(screen, "900");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("500");
A value is rounded to the field's precision
const screen = await render(<Width precision={1} />);
await type(screen, "10/3");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("3.3");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "3.3");
Text that is not a number stays as typed, marked invalid, and the value is unchanged
const onValueChange = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} />);
await type(screen, "12*");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("12*");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
expect(onValueChange).not.toHaveBeenCalled();
A press that brings focus selects the value, so that typing replaces itChromium, Firefox, WebKit

Not yet written.

Escape abandons the edit and shows the last value again
const screen = await render(<Width />);
await type(screen, "12*");
await userEvent.keyboard("{Enter}{Escape}");
await expect.element(field(screen)).toHaveValue("120");
await expect.element(field(screen)).not.toHaveAttribute("aria-invalid");
With nothing to abandon, Escape is left for what contains the field
const heard = vi.fn();
const listener = (event: KeyboardEvent) => heard(event.key);
document.addEventListener("keydown", listener);
try {
  const screen = await render(<Width />);
  await userEvent.click(field(screen));
  await userEvent.keyboard("{Escape}");
  expect(heard).toHaveBeenCalledWith("Escape");
  heard.mockClear();
  await type(screen, "12");
  await userEvent.keyboard("{Escape}");
  expect(heard).not.toHaveBeenCalled();
} finally {
  document.removeEventListener("keydown", listener);
}
Clearing the field commits no value, unless a value is required
const onValueChange = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} />);
await type(screen, "");
await userEvent.keyboard("{Enter}");
expect(onValueChange).toHaveBeenLastCalledWith(null);

const required = await render(<NumberField aria-label="Height" required defaultValue={5} />);
const height = required.getByRole("spinbutton", { name: "Height" });
await userEvent.fill(height, "");
await userEvent.keyboard("{Enter}");
await expect.element(height).toHaveAttribute("aria-invalid", "true");
The field follows the document's language when given none
document.documentElement.lang = "de-DE";
try {
  const screen = await render(<NumberField aria-label="Width" defaultValue={1234.5} />);
  await expect.element(field(screen)).toHaveValue("1.234,5");
} finally {
  document.documentElement.lang = "";
}

Stepping

Up and Down Arrow add and subtract a step
const screen = await render(<Width step={5} />);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "125");
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "115");
With Shift a step is ten times larger, with Alt ten times smaller
const screen = await render(<Width />);
await userEvent.click(field(screen));
await userEvent.keyboard("{Shift>}{ArrowUp}{/Shift}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "130");
await userEvent.keyboard("{Alt>}{ArrowDown}{/Alt}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "129.9");
Page Up and Page Down step ten at a time
const screen = await render(<Width />);
await userEvent.click(field(screen));
await userEvent.keyboard("{PageDown}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "110");
Stepping starts from what is typed, when it is a number
const screen = await render(<Width />);
await type(screen, "7*7");
await userEvent.keyboard("{ArrowUp}");
await expect.element(field(screen)).toHaveValue("50");
Stepping stops at the ends of the range
const screen = await render(<Width maximum={121} />);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp}{ArrowUp}");
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "121");
In a range that is a cycle, a step past one end arrives from the other
const screen = await render(
  <Width minimum={0} maximum={360} wrap step={10} units={angle} unit="deg" defaultValue={350} />,
);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp}{ArrowUp}");
expect((field(screen).element() as HTMLInputElement).value).toBe("10°");
await userEvent.keyboard("{PageDown}");
expect((field(screen).element() as HTMLInputElement).value).toBe("270°");
A typed value beyond a cycle is read round it — the maximum is the minimum again — and nothing was limited
const screen = await render(
  <Width minimum={0} maximum={360} wrap units={angle} unit="deg" defaultValue={350} />,
);
await type(screen, "370");
await userEvent.keyboard("{Enter}");
expect((field(screen).element() as HTMLInputElement).value).toBe("10°");
await expect.element(field(screen)).not.toHaveAttribute("data-constrained");
await expect.element(status(screen)).toHaveTextContent("");
await type(screen, "360");
await userEvent.keyboard("{Enter}");
expect((field(screen).element() as HTMLInputElement).value).toBe("0°");

Scrubbing

Dragging sideways across the field's label changes the value: a step for every two pixels
const screen = await render(<Labelled step={5} />);
await commands.dragAlong(LABEL, [4, 25]);
expect(now(screen)).toBe("170");
await commands.dragAlong(LABEL, [-4, -9]);
expect(now(screen)).toBe("160");
A click on the label still gives the field focus; the release of a scrub does not
const screen = await render(<Labelled />);
await commands.dragAlong(LABEL, [4, 15, 0]);
await wait(50);
await expect.element(field(screen)).not.toHaveFocus();
await commands.dragAlong(LABEL, []);
await expect.element(field(screen)).toHaveFocus();
// Out eleven pixels and back fifteen: five steps up, then seven down.
expect(now(screen)).toBe("118");
With Shift a scrubbed step is ten times larger, with Alt ten times smaller
const screen = await render(<Labelled />);
await userEvent.keyboard("{Shift>}");
await commands.dragAlong(LABEL, [4, 9]);
await userEvent.keyboard("{/Shift}");
expect(now(screen)).toBe("140");
await userEvent.keyboard("{Alt>}");
await commands.dragAlong(LABEL, [-4, -11]);
await userEvent.keyboard("{/Alt}");
expect(now(screen)).toBe("139.7");
A scrub lands on multiples of the step, as stepping does
const screen = await render(<Labelled step={5} defaultValue={7} />);
await commands.dragAlong(LABEL, [4, 7]);
expect(now(screen)).toBe("10");
await commands.dragAlong(LABEL, [-4, -9]);
expect(now(screen)).toBe("0");
A scrub stops at the end of the range, and a drag back from beyond it moves the value at once
const screen = await render(<Labelled maximum={125} />);
await commands.dragAlong(LABEL, [4, 105], { release: false });
expect(now(screen)).toBe("125");
// A hundred pixels past the end, and the first six back are two steps down.
await commands.dragAlong(LABEL, [99], { press: false });
expect(now(screen)).toBe("123");
In a range that is a cycle, a scrub goes round it
const screen = await render(<Labelled minimum={0} maximum={360} wrap step={10} defaultValue={350} />);
await commands.dragAlong(LABEL, [4, 9]);
expect(now(screen)).toBe("10");
await commands.dragAlong(LABEL, [-4, -11]);
expect(now(screen)).toBe("340");
In a field written right to left, a drag to the left raises the value
const screen = await render(<Labelled dir="rtl" />);
await commands.dragAlong(LABEL, [-4, -25]);
expect(now(screen)).toBe("130");
A scrub starts from what is typed, when it is a number
const screen = await render(<Labelled />);
await type(screen, "7*7");
await commands.dragAlong(LABEL, [4, 7]);
expect(now(screen)).toBe("50");
Escape during a scrub puts back the value it began from, and the rest of the drag changes nothing
const screen = await render(<Labelled />);
await commands.dragAlong(LABEL, [4, 25], { release: false });
expect(now(screen)).toBe("130");
await userEvent.keyboard("{Escape}");
expect(now(screen)).toBe("120");
await commands.dragAlong(LABEL, [45], { press: false });
expect(now(screen)).toBe("120");
A handle at the start of the field scrubs as the label does, and a click on it gives the field focus
const screen = await render(<Width scrubHandle="W" />);
await commands.dragAlong(HANDLE, [4, 25]);
expect(now(screen)).toBe("130");
await expect.element(field(screen)).not.toHaveFocus();
await commands.dragAlong(HANDLE, []);
await expect.element(field(screen)).toHaveFocus();
// The letter is a handle, not a second name: the field is still named once.
await expect.element(screen.getByText("W")).toHaveAttribute("aria-hidden", "true");
What scrubs shows a sideways resize cursor and keeps a sideways drag for itself; the field, and the page, are as they were
const screen = await render(<Labelled scrubHandle="W" />);
const handle = screen.container.querySelector(HANDLE)!;
for (const scrubs of [label(screen), handle]) {
  expect(getComputedStyle(scrubs).cursor).toBe("ew-resize");
  expect(getComputedStyle(scrubs).touchAction).toBe("pan-y");
}
expect(getComputedStyle(field(screen).element()).cursor).not.toBe("ew-resize");
expect(getComputedStyle(field(screen).element()).touchAction).toBe("auto");
expect(getComputedStyle(screen.container).touchAction).toBe("auto");
A pen and a finger scrub the label as a mouse does
const screen = await render(<Labelled />);
for (const pointerType of ["pen", "touch"]) {
  pointer(label(screen), "pointerdown", 100, { pointerType });
  pointer(label(screen), "pointermove", 103, { pointerType });
  pointer(label(screen), "pointermove", 123, { pointerType });
  pointer(document.body, "pointerup", 123, { pointerType });
}
expect(now(screen)).toBe("140");
A finger dragged sideways across the label scrubsChromium, Chromium · touch

Not yet written.

A field that cannot be changed is not scrubbed, and its label does not offer to be
const screen = await render(<Labelled readOnly scrubHandle="W" />);
expect(getComputedStyle(label(screen)).cursor).not.toBe("ew-resize");
expect(getComputedStyle(screen.container.querySelector(HANDLE)!).cursor).not.toBe("ew-resize");
await commands.dragAlong(LABEL, [4, 25]);
await commands.dragAlong(HANDLE, [4, 25]);
expect(now(screen)).toBe("120");
await screen.rerender(<Labelled disabled scrubHandle="W" />);
await commands.dragAlong(LABEL, [4, 25]);
expect(now(screen)).toBe("120");
// Once it can be changed again, it scrubs again.
await screen.rerender(<Labelled scrubHandle="W" />);
await commands.dragAlong(LABEL, [4, 25]);
expect(now(screen)).toBe("130");

Buttons that step

They are offered where the pointer is a finger — elsewhere only when asked for always
const screen = await render(<Width steppers />);
const shown = () => getComputedStyle(increase(screen).element()).display !== "none";
await expect.poll(() => screen.container.querySelector(INCREASE) !== null).toBe(true);
expect(getComputedStyle(screen.container.querySelector(INCREASE)!).display !== "none").toBe(hasTouch);
expect(getComputedStyle(screen.container.querySelector(DECREASE)!).display !== "none").toBe(hasTouch);
await screen.rerender(<Width steppers="always" />);
expect(shown()).toBe(true);
await screen.rerender(<Width />);
expect(screen.container.querySelector(INCREASE)).toBeNull();
Each has a name, says which field it controls, and is outside the Tab order: the field's own keys step
const screen = await render(<Width steppers="always" />);
const id = field(screen).element().id;
expect(id).not.toBe("");
for (const button of [decrease(screen), increase(screen)]) {
  await expect.element(button).toHaveAttribute("aria-controls", id);
  await expect.element(button).toHaveAttribute("tabindex", "-1");
}
await userEvent.click(field(screen));
await userEvent.keyboard("{Tab}");
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();
A press steps once
const screen = await render(<Width steppers="always" step={5} />);
await commands.clickSelector(INCREASE);
expect(now(screen)).toBe("125");
await commands.clickSelector(DECREASE);
await commands.clickSelector(DECREASE);
expect(now(screen)).toBe("115");
A tap steps once, on a target at least 44 pixels squareChromium · touch, WebKit · touch

Not yet written.

Held down, a button steps again after a moment and then faster and faster
const onValueChange = vi.fn();
const screen = await render(<Width steppers="always" onValueChange={onValueChange} />);
// The clock is the clause's own: a busy machine keeps time too badly to count steps by.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
  await commands.dragAlong(INCREASE, [], { release: false });
  expect(onValueChange).toHaveBeenCalledTimes(1);
  vi.advanceTimersByTime(250);
  // Not yet: a press a little long is still one step.
  expect(onValueChange).toHaveBeenCalledTimes(1);
  vi.advanceTimersByTime(950);
  // At an even pace there would be eight steps by now.
  expect(onValueChange.mock.calls.length).toBeGreaterThanOrEqual(12);
  await commands.releasePointer();
  const stopped = onValueChange.mock.calls.length;
  vi.advanceTimersByTime(300);
  expect(onValueChange).toHaveBeenCalledTimes(stopped);
} finally {
  vi.useRealTimers();
}
await expect.poll(() => Number(now(screen))).toBeGreaterThanOrEqual(132);
A held button stops at the end of the range, where it is no longer offered
const screen = await render(<Width steppers="always" minimum={118} maximum={122} />);
await commands.dragAlong(INCREASE, [], { release: false });
await expect.element(increase(screen)).toBeDisabled();
await commands.releasePointer();
expect(now(screen)).toBe("122");
await expect.element(decrease(screen)).toBeEnabled();
await commands.clickSelector(DECREASE);
await expect.element(increase(screen)).toBeEnabled();
In a range that is a cycle, neither button reaches an end
const screen = await render(
  <Width steppers="always" minimum={0} maximum={360} wrap step={90} defaultValue={270} />,
);
await commands.clickSelector(INCREASE);
expect(now(screen)).toBe("0");
await expect.element(decrease(screen)).toBeEnabled();
await commands.clickSelector(DECREASE);
expect(now(screen)).toBe("270");
The pointer leaving a held button ends the hold
const screen = await render(<Width steppers="always" />);
await commands.dragAlong(INCREASE, [200], { release: false });
await wait(700);
await commands.releasePointer();
expect(now(screen)).toBe("121");
A finger held on a button goes on stepping, until it slides offChromium, Chromium · touch

Not yet written.

A press the browser cancels, and the window losing focus, end the hold
const screen = await render(<Width steppers="always" />);
await commands.dragAlong(INCREASE, [], { release: false });
window.dispatchEvent(new Event("blur"));
await wait(700);
expect(now(screen)).toBe("121");
await commands.releasePointer();
const button = increase(screen).element();
pointer(button, "pointerdown", 0, { pointerType: "touch" });
pointer(button, "pointercancel", 0, { pointerType: "touch" });
await wait(700);
expect(now(screen)).toBe("122");
A press on a button leaves focus where it was, and steps from what is typed
const screen = await render(<Width steppers="always" />);
await type(screen, "7*7");
await commands.clickSelector(INCREASE);
await expect.element(field(screen)).toHaveFocus();
await expect.element(field(screen)).toHaveValue("50");
A button activated without a press, as assistive technology does, steps once
const screen = await render(<Width steppers="always" />);
(increase(screen).element() as HTMLButtonElement).click();
await expect.poll(() => now(screen)).toBe("121");
await wait(600);
expect(now(screen)).toBe("121");
A field that cannot be changed offers neither button
const screen = await render(<Width steppers="always" readOnly />);
await expect.element(increase(screen)).toBeDisabled();
await expect.element(decrease(screen)).toBeDisabled();

Said in words

A typed value that was held within the range is said to have been, with the limit as it is shown
const screen = await render(<Width minimum={0} maximum={500} units={length} unit="mm" />);
await expect.element(status(screen)).toHaveTextContent("");
await type(screen, "900");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("Limited to 500 mm.");
await expect.element(field(screen)).toHaveAccessibleDescription("Limited to 500\u00a0mm.");
await type(screen, "-3");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("Limited to 0 mm.");
A value that was only rounded, or stepped to the end of the range, is not said to have been limited
const screen = await render(<Width maximum={121} precision={1} />);
await type(screen, "10/3");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveValue("3.3");
await expect.element(status(screen)).toHaveTextContent("");
await type(screen, "120");
await userEvent.keyboard("{ArrowUp}{ArrowUp}");
await expect.element(field(screen)).toHaveValue("121");
await expect.element(status(screen)).toHaveTextContent("");
Why text could not be read is said, and is the field's description while it is invalid
const screen = await render(<Width />);
await expect.element(field(screen)).not.toHaveAttribute("aria-describedby");
await type(screen, "12*");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("Not a number.");
await expect.element(field(screen)).toHaveAccessibleDescription("Not a number.");
await type(screen, "12");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("");
await expect.element(field(screen)).not.toHaveAttribute("aria-describedby");
An empty field that needs a value says so
const screen = await render(<Width required />);
await type(screen, "");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("A value is required.");
Nothing is said while typing: not of what is being typed, nor again of what was typed before
const screen = await render(<Width maximum={500} />);
await type(screen, "900");
await expect.element(status(screen)).toHaveTextContent("");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("Limited to 500.");
await type(screen, "9");
await expect.element(status(screen)).toHaveTextContent("");
await type(screen, "12*");
await expect.element(status(screen)).toHaveTextContent("");

Input methods

A composition is neither read nor reformatted until it is confirmed and committedChromium, Chromium · touch

Not yet written.

A key that belongs to a composition does not commit the field
const onValueChange = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} />);
await type(screen, "64");
field(screen)
  .element()
  .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onValueChange).not.toHaveBeenCalled();
await expect.element(field(screen)).toHaveValue("64");

Touch

Where only a number of zero or more can be typed, a touchscreen offers a number pad
const screen = await render(<Width minimum={0} />);
await expect.element(field(screen)).toHaveAttribute("inputmode", "decimal");
const whole = await render(<NumberField aria-label="Count" minimum={0} precision={0} />);
await expect
  .element(whole.getByRole("spinbutton", { name: "Count" }))
  .toHaveAttribute("inputmode", "numeric");
Where a minus sign, a unit or a percent sign may be typed, it offers the full keyboard, which has them
const screen = await render(<Width />);
await expect.element(field(screen)).toHaveAttribute("inputmode", "text");
const measured = await render(<NumberField aria-label="Depth" minimum={0} units={length} unit="mm" />);
await expect
  .element(measured.getByRole("spinbutton", { name: "Depth" }))
  .toHaveAttribute("inputmode", "text");
const share = await render(<NumberField aria-label="Share" minimum={0} kind="percent" />);
await expect
  .element(share.getByRole("spinbutton", { name: "Share" }))
  .toHaveAttribute("inputmode", "text");
On a touchscreen, the field is at least 44 pixels tallChromium · touch, WebKit · touch

Not yet written.

API

onValueCommit hears a typed value once, as Enter or leaving commits it — and nothing when the value is as it was, or the text cannot be read
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} onValueCommit={onValueCommit} />);
await type(screen, "64");
expect(onValueCommit).not.toHaveBeenCalled();
await userEvent.keyboard("{Enter}");
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
expect(onValueCommit.mock.calls).toEqual([[64]]);
await type(screen, "8*8");
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
await type(screen, "12*");
await userEvent.keyboard("{Enter}{Escape}");
expect(onValueCommit.mock.calls).toEqual([[64]]);
expect(onValueChange.mock.calls).toEqual([[64]]);
While an arrow key is held, onValueChange hears every step; onValueCommit hears the last, once, when the key is let go
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} onValueCommit={onValueCommit} />);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp>4}");
expect(onValueChange.mock.calls).toEqual([[121], [122], [123], [124]]);
expect(onValueCommit).not.toHaveBeenCalled();
await userEvent.keyboard("{/ArrowUp}");
expect(onValueCommit.mock.calls).toEqual([[124]]);
A step that cannot move the value — at the end of the range — is heard by neither
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(
  <Width maximum={120} onValueChange={onValueChange} onValueCommit={onValueCommit} />,
);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp>3}{/ArrowUp}");
expect(onValueChange).not.toHaveBeenCalled();
expect(onValueCommit).not.toHaveBeenCalled();
During a scrub, onValueChange hears every value on the way; onValueCommit hears the last, once, when the pointer is released
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
await render(<Labelled onValueChange={onValueChange} onValueCommit={onValueCommit} />);
await commands.dragAlong(LABEL, [4, 9, 15], { release: false });
expect(onValueChange.mock.calls).toEqual([[122], [125]]);
expect(onValueCommit).not.toHaveBeenCalled();
await commands.releasePointer();
expect(onValueCommit.mock.calls).toEqual([[125]]);
A scrub that ends where it began commits nothing
const onValueCommit = vi.fn();
const screen = await render(<Labelled onValueCommit={onValueCommit} />);
await commands.dragAlong(LABEL, [4, 25, 4]);
expect(now(screen)).toBe("120");
expect(onValueCommit).not.toHaveBeenCalled();
Escape during a scrub: onValueChange hears the value put back, and onValueCommit nothing — then or at the release
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
await render(<Labelled onValueChange={onValueChange} onValueCommit={onValueCommit} />);
await commands.dragAlong(LABEL, [4, 9], { release: false });
await userEvent.keyboard("{Escape}");
await commands.releasePointer();
expect(onValueChange.mock.calls).toEqual([[122], [120]]);
expect(onValueCommit).not.toHaveBeenCalled();
However long a button that steps is held, onValueCommit hears once, when it is let go or reaches the end
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(
  <Width steppers="always" maximum={140} onValueChange={onValueChange} onValueCommit={onValueCommit} />,
);
await commands.dragAlong(INCREASE, [], { release: false });
await wait(700);
expect(onValueCommit).not.toHaveBeenCalled();
await commands.releasePointer();
expect(onValueChange.mock.calls.length).toBeGreaterThan(1);
expect(onValueCommit.mock.calls).toEqual([[Number(now(screen))]]);
// To the end of the range: the hold is over there, before the release.
await screen.rerender(
  <Width steppers="always" maximum={Number(now(screen)) + 1} onValueCommit={onValueCommit} />,
);
await commands.dragAlong(INCREASE, [], { release: false });
await expect.poll(() => onValueCommit.mock.calls.length).toBe(2);
await commands.releasePointer();
expect(onValueCommit).toHaveBeenCalledTimes(2);
Without scrub, a label is a label: a drag across it changes nothing, and it offers nothing
const screen = await render(<Labelled scrub={false} />);
expect(getComputedStyle(label(screen)).cursor).not.toBe("ew-resize");
await commands.dragAlong(LABEL, [4, 25]);
expect(now(screen)).toBe("120");
With scrub, a label around the field scrubs too, and a drag inside the field is the field's own
const screen = await render(
  <label data-testid="label" className="inline-block p-4">
    <span data-testid="text" className="inline-block w-24">
      Width
    </span>
    <NumberField locale="en-US" defaultValue={120} scrub className="w-40" />
  </label>,
);
await commands.dragAlong('[data-testid="text"]', [4, 25]);
expect(now(screen)).toBe("130");
await commands.dragAlong('[role="spinbutton"]', [4, 25]);
expect(now(screen)).toBe("130");
messages says in another language what the field says: its buttons' names, a limit, a reason
const screen = await render(
  <Width
    steppers="always"
    maximum={500}
    required
    messages={{
      decrease: "減らす",
      increase: "増やす",
      limited: (limit) => `${limit} までに制限されました。`,
      unreadable: { invalid: "数値ではありません。" },
    }}
  />,
);
await expect.element(screen.getByRole("button", { name: "増やす" })).toBeVisible();
await expect.element(screen.getByRole("button", { name: "減らす" })).toBeVisible();
await type(screen, "900");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("500 までに制限されました。");
await type(screen, "12*");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("数値ではありません。");
// What is not given is said as it was.
await type(screen, "");
await userEvent.keyboard("{Enter}");
await expect.element(status(screen)).toHaveTextContent("A value is required.");
A field its owner marks invalid, with aria-invalid or aria-errormessage, leaves the reason to its owner
const screen = await render(
  <>
    <Width aria-errormessage="why" />
    <span id="why">Widths are numbers.</span>
  </>,
);
await type(screen, "12*");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
await expect.element(status(screen)).toHaveTextContent("");
await expect.element(field(screen)).not.toHaveAttribute("aria-describedby");
An aria-describedby given to the field is kept beside the field's own words
const screen = await render(
  <>
    <Width aria-describedby="help" maximum={500} />
    <span id="help">In pixels.</span>
  </>,
);
await expect.element(field(screen)).toHaveAccessibleDescription("In pixels.");
await type(screen, "900");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAccessibleDescription("In pixels. Limited to 500.");
With a name, a form is sent the number, not the text that shows it
const screen = await render(
  <form aria-label="Order">
    <NumberField
      aria-label="Price"
      name="price"
      locale="en-US"
      kind="currency"
      currency="USD"
      precision={2}
      defaultValue={1234.5}
    />
  </form>,
);
const form = screen.getByRole("form").element() as HTMLFormElement;
expect(new FormData(form).getAll("price")).toEqual(["1234.5"]);
A locale that is not a language tag falls back to the document's, rather than failing
const screen = await render(<NumberField aria-label="Width" locale="en_US" defaultValue={1234.5} />);
await expect.element(screen.getByRole("spinbutton")).toBeVisible();
A field that is readOnly is not stepped
const onValueChange = vi.fn();
const screen = await render(<Width readOnly onValueChange={onValueChange} />);
await userEvent.click(field(screen));
await userEvent.keyboard("{ArrowUp}{PageDown}");
expect(onValueChange).not.toHaveBeenCalled();
A handler passed as undefined is no handler: the field still commits when it is left
const screen = await render(<Width onBlur={undefined} onKeyDown={undefined} />);
await type(screen, "64");
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
await expect.element(field(screen)).toHaveAttribute("aria-valuenow", "64");
onValueChange hears each commit that changes the value, once
const onValueChange = vi.fn();
const screen = await render(<Width onValueChange={onValueChange} />);
await type(screen, "64");
await userEvent.keyboard("{Enter}");
await userEvent.click(screen.getByRole("textbox", { name: "After" }));
expect(onValueChange.mock.calls).toEqual([[64]]);
A controlled value that changes is shown, unless the field is being edited
function Controlled() {
  const [value, setValue] = useState<number | null>(1);
  return (
    <>
      <NumberField aria-label="Width" locale="en-US" value={value} onValueChange={setValue} />
      <button type="button" onClick={() => setValue((v) => (v ?? 0) + 1)}>
        Increment
      </button>
    </>
  );
}
const screen = await render(<Controlled />);
await userEvent.click(screen.getByRole("button", { name: "Increment" }));
await expect.element(field(screen)).toHaveValue("2");
await type(screen, "50");
(screen.getByRole("button", { name: "Increment" }).element() as HTMLButtonElement).click();
await expect.element(field(screen)).toHaveValue("50");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/number-field.json
Source

src/components/number-field/number-field.tsx

"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ComponentProps, PointerEvent, ReactElement, ReactNode } from "react";
import { buttonClassName } from "../../lib/button-class.ts";
import { fieldClassName } from "../../lib/field-class.ts";
import { useLocale } from "../../lib/locale.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { cn } from "../../lib/utils.ts";
import { useAnnouncer } from "../../primitives/announcements.ts";
import { numberType } from "../../primitives/number-type.ts";
import type { NumberTypeOptions } from "../../primitives/number-type.ts";
import { useScrub } from "../../primitives/scrub.ts";
import { useField } from "../../primitives/use-field.ts";
import type { Step } from "../../primitives/use-field.ts";

/** How many of the field's steps each size of step is. */
const STEPS: Record<Step["size"], number> = { small: 0.1, normal: 1, large: 10, page: 10 };

/** A button held down steps again after this long, in milliseconds… */
const HOLD_DELAY = 400;
/** …then at this interval, each a little shorter than the last, down to the shortest. */
const HOLD_INTERVAL = 120;
const HOLD_QUICKENING = 0.85;
const HOLD_SHORTEST = 30;

/** What the field says in words; pass `messages` to say it in another language. */
export interface NumberFieldMessages {
  /** The names of the buttons that step. */
  readonly decrease: string;
  readonly increase: string;
  /** Said when what was typed was held within the range: given the value kept, as shown. */
  readonly limited: (limit: string) => string;
  /** Why what was typed is not a value, by the reason; `invalid` stands for any other. */
  readonly unreadable: Readonly<Record<string, string>>;
}

const MESSAGES: NumberFieldMessages = {
  decrease: "Decrease",
  increase: "Increase",
  limited: (limit) => `Limited to ${limit}.`,
  unreadable: {
    invalid: "Not a number.",
    empty: "A value is required.",
  },
};

export type NumberFieldProps = Omit<
  ComponentProps<"input">,
  "value" | "defaultValue" | "onChange" | "type" | "min" | "max" | "step"
> &
  NumberTypeOptions & {
    value?: number | null;
    defaultValue?: number | null;
    /** Hears every value on the way: each step of a held key, of a drag. */
    onValueChange?: (value: number | null) => void;
    /** Hears where a gesture ended — a commit, a held key let go, a drag released — once, if it moved the value. */
    onValueCommit?: (value: number | null) => void;
    /** Hears why what was committed is not a value, and `null` once it is. */
    onInvalidChange?: (reason: string | null) => void;
    /** Whether a press that brings focus selects the text, so typing replaces it. On unless turned off. */
    selectOnFocus?: boolean;
    /**
     * Whether dragging across the field's label changes the value. The label is
     * whichever the document gives the field — `<label for>`, or one around it.
     */
    scrub?: boolean;
    /** What to show at the start of the field as a handle to drag: a letter, an icon. The field keeps its own name. */
    scrubHandle?: ReactNode;
    /**
     * Buttons either side that step, held down to go on stepping: shown where
     * the pointer is a finger, or `always`.
     */
    steppers?: boolean | "always";
    messages?: Partial<NumberFieldMessages>;
  };

interface StepButtonProps {
  readonly direction: 1 | -1;
  readonly name: string;
  readonly controls: string;
  readonly disabled: boolean;
  readonly always: boolean;
  readonly onStep: (direction: 1 | -1) => void;
  /** The hold is over. */
  readonly onEnd: () => void;
}

/** A button that steps once on a press, and goes on stepping, faster, while it is held. */
function StepButton({
  direction,
  name,
  controls,
  disabled,
  always,
  onStep,
  onEnd,
}: StepButtonProps): ReactElement {
  const timer = useRef<number>(undefined);
  const holding = useRef(false);
  // Whether the click now arriving ends a press, which has stepped already.
  const pressed = useRef(false);

  const stop = useCallback(() => {
    window.clearTimeout(timer.current);
    if (!holding.current) return;
    holding.current = false;
    onEnd();
  }, [onEnd]);

  // The end of the range is the end of the hold: a disabled button hears no release.
  useEffect(() => {
    if (disabled) stop();
  }, [disabled, stop]);

  useEffect(() => {
    window.addEventListener("blur", stop);
    return () => {
      window.removeEventListener("blur", stop);
      stop();
    };
  }, [stop]);

  const start = (event: PointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0 || holding.current) return;
    // A finger's press is captured by what it lands on — the sign, not the button.
    // Let go of it, so that leaving the button is heard.
    const landed = event.target as Element;
    if (landed.hasPointerCapture(event.pointerId)) landed.releasePointerCapture(event.pointerId);
    pressed.current = true;
    holding.current = true;
    onStep(direction);
    let interval = HOLD_INTERVAL;
    const repeat = () => {
      onStep(direction);
      interval = Math.max(HOLD_SHORTEST, interval * HOLD_QUICKENING);
      timer.current = window.setTimeout(repeat, interval);
    };
    timer.current = window.setTimeout(repeat, HOLD_DELAY);
  };

  return (
    <button
      type="button"
      // The field's own arrow keys step: these are for a pointer.
      tabIndex={-1}
      aria-label={name}
      aria-controls={controls}
      disabled={disabled}
      data-step={direction === 1 ? "increase" : "decrease"}
      className={cn(
        buttonClassName,
        "w-8 shrink-0 touch-manipulation justify-center px-0 pointer-coarse:w-11",
        always ? "flex" : "hidden pointer-coarse:flex",
      )}
      // Focus stays where it is: in the field, if it was being typed in.
      onMouseDown={(event) => event.preventDefault()}
      onPointerDown={start}
      onPointerUp={stop}
      onPointerLeave={stop}
      onPointerCancel={stop}
      onClick={() => {
        if (pressed.current) {
          pressed.current = false;
          return;
        }
        // Activated without a press, as assistive technology does.
        onStep(direction);
        onEnd();
      }}
    >
      <span aria-hidden="true">{direction === 1 ? "+" : "−"}</span>
    </button>
  );
}

/**
 * A field for a number: typed freely — with arithmetic, units, any digits and
 * separators — committed on Enter or on leaving, stepped with the arrow keys,
 * and, where asked, scrubbed by its label or stepped by buttons.
 */
export function NumberField({
  value,
  defaultValue,
  onValueChange,
  onValueCommit,
  locale: localeProp,
  minimum,
  maximum,
  wrap,
  step,
  precision,
  kind,
  currency,
  units,
  unit,
  required,
  onInvalidChange,
  selectOnFocus = true,
  scrub = false,
  scrubHandle,
  steppers = false,
  messages: messagesProp,
  id: idProp,
  name,
  form,
  className,
  "aria-describedby": describedBy,
  ...props
}: NumberFieldProps): ReactElement {
  const locale = useLocale(localeProp);
  const type = useMemo(() => {
    const number = numberType({
      locale,
      minimum,
      maximum,
      wrap,
      step,
      precision,
      kind,
      currency,
      units,
      unit,
    });
    return {
      ...number,
      origin: number.constrain(0),
      step: (from: number, asked: Step) =>
        number.step(from, asked.direction * STEPS[asked.size], asked.count),
    };
  }, [locale, minimum, maximum, wrap, step, precision, kind, currency, units, unit]);
  const field = useField({
    type,
    value,
    defaultValue,
    onValueChange,
    onValueCommit,
    required,
    onInvalidChange,
    selectOnFocus,
  });
  const messages = useMemo(
    () => ({
      ...MESSAGES,
      ...messagesProp,
      unreadable: { ...MESSAGES.unreadable, ...messagesProp?.unreadable },
    }),
    [messagesProp],
  );
  const generated = useId();
  const id = idProp ?? generated;
  const statusId = `${generated}-status`;
  const [input, setInput] = useState<HTMLInputElement | null>(null);

  const { step: stepField, end: endField, cancel: cancelField } = field;
  const fixed = props.disabled === true || props.readOnly === true;
  const attach = useScrub({
    onScrub: (steps, size) => stepField({ direction: steps > 0 ? 1 : -1, size, count: Math.abs(steps) }),
    onScrubEnd: endField,
    onScrubCancel: cancelField,
  });
  const stepOnce = useCallback((direction: 1 | -1) => stepField({ direction, size: "normal" }), [stepField]);

  // The labels are the document's to give — `for`, or a label around the field —
  // and may be given at any time, so they are looked for after every render.
  const labels = useRef(new Map<HTMLElement, () => void>());
  useEffect(() => {
    const now: HTMLElement[] = scrub && !fixed ? [...(input?.labels ?? [])] : [];
    for (const [label, detach] of labels.current) {
      if (now.includes(label)) continue;
      detach();
      labels.current.delete(label);
    }
    for (const label of now) {
      const detach = labels.current.has(label) ? undefined : attach(label);
      if (detach !== undefined) labels.current.set(label, detach);
    }
  });
  useEffect(() => {
    const attached = labels.current;
    return () => {
      for (const detach of attached.values()) detach();
      attached.clear();
    };
  }, []);

  // A phone's number pads have no minus sign, no letters and no operators: they
  // serve only where a plain number of zero or more is all there is to type.
  const plain = minimum !== undefined && minimum >= 0 && units === undefined && kind !== "percent";
  // What the value is, stated only while the text is that value: not while another is being typed.
  const stated = field.editing ? null : field.value;

  // In words, what the marks on the field show. An owner that marks the field
  // invalid itself, or names where the reason is, is the one to say why.
  const spoken = props["aria-invalid"] !== undefined || props["aria-errormessage"] !== undefined;
  const said =
    field.invalid !== null
      ? spoken
        ? ""
        : (messages.unreadable[field.invalid] ?? messages.unreadable.invalid ?? "")
      : field.constrained && field.value !== null
        ? messages.limited(type.format(field.value))
        : "";
  const described = [describedBy, said === "" ? undefined : statusId].filter(Boolean).join(" ");
  const { announce, regionProps: statusProps } = useAnnouncer();
  useEffect(() => announce(said), [announce, said]);

  const control = (
    <input
      type="text"
      role="spinbutton"
      inputMode={plain ? (precision === 0 ? "numeric" : "decimal") : "text"}
      enterKeyHint="done"
      autoComplete="off"
      autoCapitalize="off"
      autoCorrect="off"
      spellCheck={false}
      aria-required={required || undefined}
      aria-valuenow={stated ?? undefined}
      aria-valuemin={minimum}
      aria-valuemax={maximum}
      aria-valuetext={stated === null ? undefined : type.format(stated)}
      data-invalid={field.invalid === null ? undefined : ""}
      form={form}
      {...mergeProps(mergeProps(field.inputProps, { ref: setInput }), props)}
      id={id}
      aria-describedby={described === "" ? undefined : described}
      className={cn(fieldClassName, scrubHandle !== undefined && "ps-7", className)}
    />
  );
  const rest = (
    <>
      {/* What is said is also the field's description, for as long as it holds. */}
      <span id={statusId} {...statusProps} />
      {/* A form is sent the number, not the text that shows it. */}
      {name !== undefined && (
        <input type="hidden" name={name} form={form} value={field.value ?? ""} disabled={props.disabled} />
      )}
    </>
  );

  if (scrubHandle === undefined && steppers === false) {
    return (
      <>
        {control}
        {rest}
      </>
    );
  }

  const least =
    !type.wrap && type.minimum !== undefined && field.value !== null && field.value <= type.minimum;
  const most =
    !type.wrap && type.maximum !== undefined && field.value !== null && field.value >= type.maximum;
  const button = { controls: id, always: steppers === "always", onStep: stepOnce, onEnd: endField };

  return (
    <div data-number-field="" className="flex w-full min-w-0 items-center gap-1">
      {steppers !== false && (
        <StepButton direction={-1} name={messages.decrease} disabled={fixed || least} {...button} />
      )}
      <div className="relative min-w-0 flex-1">
        {scrubHandle !== undefined && (
          // The field has its own name, and the keyboard its own ways to step: this is for a pointer only.
          // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
          <span
            ref={fixed ? undefined : attach}
            aria-hidden="true"
            data-scrub-handle=""
            data-disabled={props.disabled === true ? "" : undefined}
            className="absolute inset-y-0 start-0 grid w-7 place-items-center text-xs text-fg-muted data-disabled:text-fg-disabled"
            onClick={() => input?.focus()}
          >
            {scrubHandle}
          </span>
        )}
        {control}
      </div>
      {steppers !== false && (
        <StepButton direction={1} name={messages.increase} disabled={fixed || most} {...button} />
      )}
      {rest}
    </div>
  );
}