Foundations
Editing values
Letting people type freely into a field that must end up holding a valid value.
MarkdownA field that holds a typed value — a number, a date, a measurement — has two jobs that pull against each other: to let people type freely, and to end up holding a valid value. Editing well means keeping the two apart: typing is typing, and the value is settled only when people say they are done.
Typing, committing, abandoning
Type a date and watch the text and the value part ways until Return or leaving the field. Type 2/30 and commit; press Escape. Put the caret in the month and press Up Arrow.
- State
- settled
- Text
- Oct 13, 2026
- Value
- 2026-10-13
- Commits
- 0
Code
"use client";
import { fieldClassName } from "@publictheta/ui/lib/field-class.ts";
import { dateType } from "@publictheta/ui/primitives/date-type.ts";
import { useField } from "@publictheta/ui/primitives/use-field.ts";
import { useId, useMemo, useState } from "react";
export default function FieldStates() {
const id = useId();
const type = useMemo(() => dateType({ locale: "en-US" }), []);
const [value, setValue] = useState<string | null>("2026-10-13");
const [commits, setCommits] = useState(0);
const field = useField({
type,
value,
onValueChange(next) {
setValue(next);
setCommits((count) => count + 1);
},
});
const formatted = value === null ? "" : type.format(value);
const state = field.invalid !== null ? "invalid" : field.text !== formatted ? "editing" : "settled";
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm">
<label htmlFor={id} className="text-fg-muted">
Date
</label>
<input
id={id}
{...field.inputProps}
data-invalid={field.invalid === null ? undefined : ""}
className={fieldClassName}
/>
</div>
<dl aria-live="polite" className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-fg-muted">State</dt>
<dd className="font-medium text-fg-strong">{state}</dd>
<dt className="text-fg-muted">Text</dt>
<dd className="font-mono text-fg-strong">{field.text === "" ? "—" : field.text}</dd>
<dt className="text-fg-muted">Value</dt>
<dd className="font-mono text-fg-strong">{value ?? "none"}</dd>
<dt className="text-fg-muted">Commits</dt>
<dd className="font-mono text-fg-strong">{commits}</dd>
</dl>
</div>
);
}Best practices
Leave typing alone. While people type, change nothing: do not reformat, round, move the caret or report a problem. Text that is halfway to a value is not an error.
Read the value at commit. People commit by pressing Return (Enter) or by leaving the field. Only then is the text read; a value is constrained to what the field allows and shown in its proper form, so people see what was kept.
Do not read again what was not changed. Text that is still the value as shown is the value. Reading it back could only lose what the display leaves out, such as digits beyond the precision shown.
Leaving for another window is not leaving the field. When another application takes focus, what was being typed is still being typed on return; nothing is committed, and nothing is marked wrong.
Commit what was filled in. Text that a browser or a password manager puts in a field that does not have focus is committed as it arrives: no blur will follow to commit it.
Keep what could not be read. When committed text is not a value, leave it as typed and mark the field invalid, saying why. Correcting a slip is quicker than typing again, and a field that silently restores its old value hides the mistake.
Let Escape take an edit back. Escape abandons what was typed and shows the last value. When there is nothing to abandon, Escape belongs to whatever contains the field — a popover, a dialog — so it must not be swallowed.
Let the input method finish. While an input method is composing, every key belongs to it. Return confirms the composition; it does not commit the field. The composed text is not read, replaced or judged until it is confirmed.
Step with the arrow keys. Up and Down Arrow change the value by a step; Shift makes the step larger, and Option or Alt smaller where the type has a smaller step. Stepping starts from what is typed, when it reads as a value.
Tell a change from its end. A value can pass through many values on the way to one: an arrow key held down, a label dragged, a button held. Show every value as it is reached, and report the whole as one change when the gesture ends — the key let go, the pointer released, focus gone elsewhere while the key was still down. Typing ends as it is committed. A gesture that leaves the value where it found it is no change, and one that is taken back — Escape during a drag — puts the first value back and reports nothing. An application then has one entry to undo for each thing people did.
Step from outside as the keys step. A drag or a button that changes the value makes the field's own steps: from what is typed, when it reads as a value; on the same multiples; not at all in a field that cannot be changed.
Step the part the caret is in. Where a value is shown in parts — the day, month and year of a date — the arrows change the part the caret is in and leave that part selected, so the next press continues and typing replaces it. With a part selected, Left and Right Arrow move from part to part. A caret at the very end of the text is in no part: that is where a press beside the text puts it.
Start stepping where the type starts. An empty field's first step is to the type's own starting point — today, zero, the nearest end of a range — and not a step beyond it.
Leave the command key's arrows to the text. With Command or Control, the arrows move the caret to the start and end, as in any field.
Do not step what cannot be changed. A read-only or disabled field shows its value and does not answer the arrows.
Select on a press where typing should replace. Where a value is shown with its unit, a press that brings focus selects the whole text, as Tab does, so that typing replaces it rather than landing inside it. A second press places the caret.
Submit what was typed. In a form, Return commits the field and then submits the form — with the value just read, as a number or a date in the form machines share rather than the text shown. If the text cannot be read, the form is not submitted. Resetting the form shows the field as it began.
Replace unread text when the value is replaced. Text that could not be read stays until it is dealt with — unless, with focus elsewhere, the value is changed from outside: it is another value's field now.
Show a changed value, but not over an edit. When the value changes from outside, show it — unless the field is being edited, in which case what is being typed stays until it is committed or abandoned.
Platform considerations
Touch. Offer the keyboard that suits the type, and make the field at least 44 CSS pixels tall.
Assistive technology. State when a field is invalid, and associate the reason with it.
Behaviour
Committing
What is typed is left as typed, and the value unchanged, until it is committed
const screen = await render(<Word />);
await userEvent.fill(input(screen), "second");
await expect.element(input(screen)).toHaveValue("second");
await expect.element(state(screen)).toHaveTextContent("FIRST / valid");Enter commits: the text is read as a value and shown in the type's form
const screen = await render(<Word />);
await userEvent.fill(input(screen), "second");
await userEvent.keyboard("{Enter}");
await expect.element(input(screen)).toHaveValue("SECOND");
await expect.element(state(screen)).toHaveTextContent("SECOND / valid");Leaving the field commits
const screen = await render(<Word />);
await userEvent.fill(input(screen), "second");
await userEvent.click(input(screen, "After"));
await expect.element(state(screen)).toHaveTextContent("SECOND / valid");Text that is not a value stays as typed, with the reason, and the value is unchanged
const screen = await render(<Word />);
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}");
await expect.element(input(screen)).toHaveValue("two words");
await expect.element(input(screen)).toHaveAttribute("aria-invalid", "true");
await expect.element(state(screen)).toHaveTextContent("FIRST / not a word");Correcting the text and committing again clears the problem
const screen = await render(<Word />);
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}");
await userEvent.fill(input(screen), "twowords");
await userEvent.keyboard("{Enter}");
await expect.element(input(screen)).not.toHaveAttribute("aria-invalid");
await expect.element(state(screen)).toHaveTextContent("TWOWORDS / valid");An emptied field commits no value, unless a value is required
const screen = await render(<Word />);
await userEvent.fill(input(screen), "");
await userEvent.keyboard("{Enter}");
await expect.element(state(screen)).toHaveTextContent("null / valid");
const required = await render(<Field label="Required" type={word} defaultValue="FIRST" required />);
await userEvent.fill(input(required, "Required"), "");
await userEvent.keyboard("{Enter}");
await expect.element(input(required, "Required")).toHaveAttribute("aria-invalid", "true");Leaving
Leaving for another window is not leaving the field: nothing is committed
const screen = await render(<Word />);
await userEvent.fill(input(screen), "two words");
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false);
try {
element(screen, "Word").dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 50));
} finally {
hasFocus.mockRestore();
}
await expect.element(input(screen)).toHaveValue("two words");
await expect.element(state(screen)).toHaveTextContent("FIRST / valid");Text that arrives without focus, as a browser fills a field in, is committed as it arrives
const screen = await render(<Word />);
const field = element(screen, "Word");
// As a browser sets a value: through the element's own setter, with an input event.
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(field, "filled");
field.dispatchEvent(new Event("input", { bubbles: true }));
await expect.element(state(screen)).toHaveTextContent("FILLED / valid");Text that is the value as shown is not read again
// Shown with less than it holds: reading the text back would lose the rest.
const rounded: FieldType<number> = {
parse: (text) => ({ ok: true, value: Number(text) }),
format: (value) => value.toFixed(1),
};
const onValueChange = vi.fn();
const screen = await render(
<Field label="Number" type={rounded} defaultValue={1.25} onValueChange={onValueChange} />,
);
await userEvent.click(input(screen, "Number"));
await userEvent.keyboard("{End}0{Backspace}{Enter}");
await expect.element(input(screen, "Number")).toHaveValue("1.3");
expect(onValueChange).not.toHaveBeenCalled();In a form
Enter commits, then submits the form with the value just typed
const onSubmit = vi.fn();
const screen = await render(<Form onSubmit={onSubmit} />);
await userEvent.fill(input(screen), "second");
await userEvent.keyboard("{Enter}");
expect(onSubmit.mock.calls).toEqual([["SECOND"]]);Enter on text that is not a value does not submit the form
const onSubmit = vi.fn();
const screen = await render(<Form onSubmit={onSubmit} />);
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}");
await expect.element(input(screen)).toHaveAttribute("aria-invalid", "true");
expect(onSubmit).not.toHaveBeenCalled();Resetting the form shows the field as it began
const screen = await render(<Form onSubmit={() => {}} />);
await userEvent.fill(input(screen), "second");
await userEvent.keyboard("{Tab}");
await userEvent.fill(input(screen), "two words");
await userEvent.click(screen.getByRole("button", { name: "Start over" }));
await expect.element(input(screen)).toHaveValue("FIRST");
await expect.element(input(screen)).not.toHaveAttribute("aria-invalid");Abandoning
Escape abandons the edit and shows the last value again
const screen = await render(<Word />);
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}{Escape}");
await expect.element(input(screen)).toHaveValue("FIRST");
await expect.element(input(screen)).not.toHaveAttribute("aria-invalid");Escape reaches what contains the field only when there is no edit to abandon
const heard = vi.fn();
const listener = (event: KeyboardEvent) => heard(event.key);
document.addEventListener("keydown", listener);
try {
const screen = await render(<Word />);
await userEvent.fill(input(screen), "second");
await userEvent.keyboard("{Escape}");
expect(heard).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}");
expect(heard).toHaveBeenCalledWith("Escape");
} finally {
document.removeEventListener("keydown", listener);
}Input methods
A key that belongs to a composition does not commit
const screen = await render(<Word />);
await userEvent.fill(input(screen), "second");
element(screen, "Word").dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }),
);
await expect.element(state(screen)).toHaveTextContent("FIRST / valid");Text being composed is neither read nor replacedChromium, Chromium · touch
Not yet written.
Stepping
An arrow steps the part of the value the caret is in, and leaves that part selected
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").setSelectionRange(1, 1);
await userEvent.keyboard("{ArrowUp}");
await expect.element(input(screen, "Date")).toHaveValue("Nov 13, 2026");
const field = element(screen, "Date");
expect(field.value.slice(field.selectionStart ?? 0, field.selectionEnd ?? 0)).toBe("Nov");
await userEvent.keyboard("{ArrowUp}{ArrowUp}");
await expect.element(input(screen, "Date")).toHaveValue("Jan 13, 2027");With the whole value selected, an arrow makes the type's ordinary step
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").select();
await userEvent.keyboard("{ArrowUp}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 14, 2026");Shift makes a large step, and the Page keys a page
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").select();
await userEvent.keyboard("{Shift>}{ArrowDown}{/Shift}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 6, 2026");
element(screen, "Date").select();
await userEvent.keyboard("{PageUp}");
await expect.element(input(screen, "Date")).toHaveValue("Nov 6, 2026");Stepping starts from what is typed, when it reads as a value
const screen = await render(<DateText />);
await userEvent.fill(input(screen, "Date"), "10/20");
await userEvent.keyboard("{ArrowUp}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 21, 2026");An empty field's first step is to where the type starts
const screen = await render(<Field label="Date" type={dates} />);
await userEvent.click(input(screen, "Date"));
await userEvent.keyboard("{ArrowDown}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 13, 2026");With a part selected, the arrows to either side select the part on that side
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
const field = element(screen, "Date");
const selected = () => field.value.slice(field.selectionStart ?? 0, field.selectionEnd ?? 0);
field.setSelectionRange(4, 6);
await userEvent.keyboard("{ArrowRight}");
expect(selected()).toBe("2026");
await userEvent.keyboard("{ArrowLeft}{ArrowLeft}");
expect(selected()).toBe("Oct");
// Past the first part there is none: the arrow is the text's own again.
await userEvent.keyboard("{ArrowLeft}");
expect(selected()).toBe("");A caret at the very end of the text is in no part
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").setSelectionRange(12, 12);
await userEvent.keyboard("{ArrowUp}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 14, 2026");With the command key, the arrows are left to the text
const screen = await render(<DateText />);
await userEvent.click(input(screen, "Date"));
await userEvent.keyboard("{Meta>}{ArrowUp}{/Meta}{Control>}{ArrowUp}{/Control}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 13, 2026");A field that is read-only is not stepped
const screen = await render(<Field label="Date" type={dates} defaultValue="2026-10-13" readOnly />);
await userEvent.click(input(screen, "Date"));
await userEvent.keyboard("{ArrowUp}{PageDown}");
await expect.element(input(screen, "Date")).toHaveValue("Oct 13, 2026");A type that does not step leaves the arrow keys to the text
const screen = await render(<Word />);
await userEvent.click(input(screen));
await userEvent.keyboard("{ArrowUp}{ArrowDown}");
await expect.element(state(screen)).toHaveTextContent("FIRST / valid");A change and its end
What is typed is one change, which ends as it is committed
const onValueCommit = vi.fn();
const screen = await render(<Word onValueCommit={onValueCommit} />);
await userEvent.fill(input(screen), "second");
expect(onValueCommit).not.toHaveBeenCalled();
await userEvent.keyboard("{Enter}");
await userEvent.click(input(screen, "After"));
expect(onValueCommit.mock.calls).toEqual([["SECOND"]]);
await userEvent.fill(input(screen), "");
await userEvent.click(input(screen, "After"));
expect(onValueCommit.mock.calls).toEqual([["SECOND"], [null]]);A commit that leaves the value as it was, or that cannot be read, ends nothing
const onValueCommit = vi.fn();
const screen = await render(<Word onValueCommit={onValueCommit} />);
await userEvent.fill(input(screen), "first");
await userEvent.keyboard("{Enter}");
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Escape}");
expect(onValueCommit).not.toHaveBeenCalled();A key held down changes the value at every repeat, and the change ends once, when the key is let go
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(
<Field
label="Date"
type={dates}
defaultValue="2026-10-13"
onValueChange={onValueChange}
onValueCommit={onValueCommit}
/>,
);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").select();
await userEvent.keyboard("{ArrowUp>3}");
expect(onValueChange.mock.calls).toEqual([["2026-10-14"], ["2026-10-15"], ["2026-10-16"]]);
expect(onValueCommit).not.toHaveBeenCalled();
await userEvent.keyboard("{/ArrowUp}");
expect(onValueCommit.mock.calls).toEqual([["2026-10-16"]]);Steps that bring the value back to where it was end nothing
const onValueCommit = vi.fn();
const screen = await render(
<Field label="Date" type={dates} defaultValue="2026-10-13" onValueCommit={onValueCommit} />,
);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").select();
// Down while Up is still held: one gesture, ended by the first key let go.
await userEvent.keyboard("{ArrowUp>}{ArrowDown}{/ArrowUp}");
await expect.element(state(screen)).toHaveTextContent("2026-10-13 / valid");
expect(onValueCommit).not.toHaveBeenCalled();Leaving the field while a key is held ends the change there
const onValueCommit = vi.fn();
const screen = await render(
<Field label="Date" type={dates} defaultValue="2026-10-13" onValueCommit={onValueCommit} />,
);
await userEvent.click(input(screen, "Date"));
element(screen, "Date").select();
await userEvent.keyboard("{ArrowUp>2}");
await userEvent.click(input(screen, "After"));
expect(onValueCommit.mock.calls).toEqual([["2026-10-15"]]);
await userEvent.keyboard("{/ArrowUp}");
expect(onValueCommit).toHaveBeenCalledTimes(1);API
set puts a value in the field that was not typed, in place of text that could not be read, as one change
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
function Settable() {
const field = useField({ type: word, defaultValue: "FIRST", onValueChange, onValueCommit });
return (
<>
<input aria-label="Word" {...field.inputProps} />
<button type="button" onClick={() => field.set("CHOSEN")}>
Choose
</button>
</>
);
}
const screen = await render(<Settable />);
await userEvent.fill(input(screen), "two words");
await userEvent.keyboard("{Enter}");
await expect.element(input(screen)).toHaveAttribute("aria-invalid", "true");
await userEvent.click(screen.getByRole("button", { name: "Choose" }));
await expect.element(input(screen)).toHaveValue("CHOSEN");
await expect.element(input(screen)).not.toHaveAttribute("aria-invalid");
expect(onValueChange.mock.calls).toEqual([["CHOSEN"]]);
expect(onValueCommit.mock.calls).toEqual([["CHOSEN"]]);step steps as an arrow key does, and end reports where the steps ended, once
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(<Outside onValueChange={onValueChange} onValueCommit={onValueCommit} />);
press(screen, "Step");
press(screen, "Step");
await expect.element(input(screen, "Date")).toHaveValue("Oct 15, 2026");
expect(onValueChange.mock.calls).toEqual([["2026-10-14"], ["2026-10-15"]]);
expect(onValueCommit).not.toHaveBeenCalled();
press(screen, "End");
press(screen, "End");
expect(onValueCommit.mock.calls).toEqual([["2026-10-15"]]);Each step starts from the last, though no render has come between for the caller to see
const onValueChange = vi.fn();
const screen = await render(<Outside onValueChange={onValueChange} />);
press(screen, "Twice");
await expect.element(input(screen, "Date")).toHaveValue("Oct 15, 2026");
expect(onValueChange.mock.calls).toEqual([["2026-10-14"], ["2026-10-15"]]);step starts from what is typed, when it reads as a value
const screen = await render(<Outside />);
await userEvent.fill(input(screen, "Date"), "1 jan 2027");
press(screen, "Step");
await expect.element(input(screen, "Date")).toHaveValue("Jan 2, 2027");cancel puts back the value the steps began from, and reports no end
const onValueChange = vi.fn();
const onValueCommit = vi.fn();
const screen = await render(<Outside onValueChange={onValueChange} onValueCommit={onValueCommit} />);
press(screen, "Step");
press(screen, "Step");
press(screen, "Cancel");
await expect.element(input(screen, "Date")).toHaveValue("Oct 13, 2026");
expect(onValueChange).toHaveBeenLastCalledWith("2026-10-13");
press(screen, "End");
expect(onValueCommit).not.toHaveBeenCalled();
// With nothing begun, there is nothing to put back.
press(screen, "Cancel");
expect(onValueChange).toHaveBeenCalledTimes(3);step does not step a field that is read-only
const onValueChange = vi.fn();
const screen = await render(<Outside readOnly onValueChange={onValueChange} />);
press(screen, "Step");
expect(onValueChange).not.toHaveBeenCalled();onValueChange hears each commit that changes the value, once
const onValueChange = vi.fn();
const screen = await render(<Word onValueChange={onValueChange} />);
await userEvent.fill(input(screen), "second");
await userEvent.keyboard("{Enter}");
await userEvent.click(input(screen, "After"));
expect(onValueChange.mock.calls).toEqual([["SECOND"]]);A controlled value that changes is shown, unless the field is being edited
function Controlled() {
const [value, setValue] = useState<string | null>("ONE");
return (
<>
<Field label="Word" type={word} value={value} onValueChange={setValue} />
<button type="button" onClick={() => setValue("TWO")}>
Set
</button>
</>
);
}
const screen = await render(<Controlled />);
await userEvent.fill(input(screen), "typing");
(screen.getByRole("button", { name: "Set" }).element() as HTMLButtonElement).click();
await expect.element(input(screen)).toHaveValue("typing");
await userEvent.keyboard("{Escape}");
await expect.element(input(screen)).toHaveValue("TWO");A value given while unread text is left in the field, and focus is elsewhere, replaces it
function Controlled() {
const [value, setValue] = useState<string | null>("ONE");
return (
<>
<Field label="Word" type={word} value={value} onValueChange={setValue} />
<button type="button" onClick={() => setValue("TWO")}>
Set
</button>
</>
);
}
const screen = await render(<Controlled />);
await userEvent.fill(input(screen), "two words");
await userEvent.click(input(screen, "After"));
await expect.element(input(screen)).toHaveAttribute("aria-invalid", "true");
(screen.getByRole("button", { name: "Set" }).element() as HTMLButtonElement).click();
await expect.element(input(screen)).toHaveValue("TWO");
await expect.element(input(screen)).not.toHaveAttribute("aria-invalid");With selectOnFocus, a press that brings focus selects the text; a drag that selects is left alone
const screen = await render(<Word selectOnFocus />);
await userEvent.click(input(screen));
const field = element(screen, "Word");
expect([field.selectionStart, field.selectionEnd]).toEqual([0, 5]);
// Focus is here already: a second press places the caret.
await userEvent.click(input(screen));
expect(field.selectionStart).toBe(field.selectionEnd);
// And so does a press in a field that the keyboard brought focus to.
await userEvent.click(input(screen, "After"));
field.focus();
await userEvent.click(input(screen));
expect(field.selectionStart).toBe(field.selectionEnd);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/use-field.jsonSource
src/primitives/use-field.ts
/**
* The editing behaviour every typed field shares, whatever its value type:
*
* - What is typed is kept as typed until it is committed — by Enter, or by
* leaving the field — so nothing reformats or complains mid-keystroke.
* - A commit reads the text through the type. A value is constrained and shown
* in the type's format; text that is not a value stays, marked invalid.
* - Escape abandons the edit and shows the last value again. With nothing to
* abandon, Escape is left for whatever contains the field.
* - Arrow keys step the value: Shift for a large step, Alt for a small one. In
* a value shown in parts — a date's day, month and year — they step the part
* the caret is in, and leave that part selected; the arrows to either side
* then move from part to part.
* - While an input method is composing, keys belong to it: Enter confirms the
* composition rather than committing the field.
* - A field that cannot be changed — read-only, or disabled — is not stepped.
* - Leaving for another window is not leaving the field: what was being typed
* is still being typed on return.
* - A change is told apart from its end. Every value on the way is a change; a
* gesture — a commit of what was typed, an arrow held down, a drag — is over
* once, and is heard then only if it left the value other than it found it.
*/
import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
import type { ChangeEvent, FocusEvent, KeyboardEvent, PointerEvent } from "react";
import { flushSync } from "react-dom";
import { useControllableState } from "./use-controllable-state.ts";
export type ParseResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly reason: string };
export interface Step {
readonly direction: 1 | -1;
/** Arrow keys are `normal`; Shift makes them `large`, Alt `small`; Page keys are `page`. */
readonly size: "small" | "normal" | "large" | "page";
/** The part the caret is in, for a value shown in parts. */
readonly part?: string | undefined;
/** How many such steps at once, as a drag makes them; one when not given. */
readonly count?: number | undefined;
}
export interface Part {
/** `literal` for the text between parts; otherwise the type's name for the part. */
readonly type: string;
readonly text: string;
}
export interface FieldType<T> {
readonly parse: (text: string) => ParseResult<T>;
readonly format: (value: T) => string;
/** The formatted value in its parts, which join to what `format` returns. */
readonly parts?: (value: T) => readonly Part[];
/** Rounding, clamping: what a parsed value becomes before it is kept. */
readonly constrain?: (value: T) => T;
/** The value after a step; absent for a type that does not step. */
readonly step?: (value: T, step: Step) => T;
/** Where stepping starts when the field is empty. */
readonly origin?: T;
}
export interface FieldOptions<T> {
readonly type: FieldType<T>;
readonly value?: T | null | undefined;
readonly defaultValue?: T | null | undefined;
readonly onValueChange?: ((value: T | null) => void) | undefined;
/**
* Hears the value a gesture ended on, once, when that is not the value it
* began on: a commit of what was typed; the release of a key that stepped,
* however long it was held; the `end` of what `step` began.
*/
readonly onValueCommit?: ((value: T | null) => void) | undefined;
/** Whether an empty field is invalid rather than a null value. */
readonly required?: boolean | undefined;
/**
* Whether the text has lines. Enter then makes a new line, and the command
* key with Enter commits.
*/
readonly multiline?: boolean | undefined;
/** Hears the reason text could not be read as a value, and `null` once it can. */
readonly onInvalidChange?: ((reason: string | null) => void) | undefined;
/**
* Whether a press that brings focus selects the whole text, so that typing
* replaces a value shown with its unit — as Tab does in any field.
*/
readonly selectOnFocus?: boolean | undefined;
}
/** A field's element: a line of text, or several. */
export type FieldElement = HTMLInputElement | HTMLTextAreaElement;
export interface Field<T> {
readonly value: T | null;
readonly text: string;
/** Why the text is not a value, after a commit that failed. */
readonly invalid: string | null;
/** Whether text is being typed that has yet to be committed. */
readonly editing: boolean;
/** Whether the last commit kept a different value from the one typed: one held within a range. */
readonly constrained: boolean;
/** Read what is typed, now. Returns whether it was a value, or there was nothing to read. */
readonly commit: () => boolean;
/**
* Put a value in the field that was not typed — a day chosen from a calendar.
* It takes the place of whatever was typed, read or not, and is one change.
*/
readonly set: (value: T | null) => void;
/**
* Step as an arrow key does, from outside the field — a drag, a button held
* down. The first begins a gesture, which `end` or `cancel` finishes. All
* three are the same functions for the life of the field.
*/
readonly step: (step: Step) => void;
/** The gesture is over: `onValueCommit` hears where it ended, if it moved the value. */
readonly end: () => void;
/** The gesture is taken back: the value is what it was when the gesture began, and nothing is committed. */
readonly cancel: () => void;
readonly inputProps: {
readonly ref: (node: FieldElement | null) => void;
readonly value: string;
readonly "aria-invalid": true | undefined;
readonly "data-constrained": "" | undefined;
readonly onChange: (event: ChangeEvent<FieldElement>) => void;
readonly onKeyDown: (event: KeyboardEvent<FieldElement>) => void;
readonly onKeyUp: (event: KeyboardEvent<FieldElement>) => void;
readonly onFocus: (event: FocusEvent<FieldElement>) => void;
readonly onPointerUp: (event: PointerEvent<FieldElement>) => void;
readonly onBlur: (event: FocusEvent<FieldElement>) => void;
};
}
/** The step a key asks for, or null. */
function stepFor(event: KeyboardEvent): Pick<Step, "direction" | "size"> | null {
// With the command key, the arrows are the text's own: to its start and end.
if (event.metaKey || event.ctrlKey) return null;
const size = event.shiftKey ? "large" : event.altKey ? "small" : "normal";
switch (event.key) {
case "ArrowUp":
return { direction: 1, size };
case "ArrowDown":
return { direction: -1, size };
case "PageUp":
return { direction: 1, size: "page" };
case "PageDown":
return { direction: -1, size: "page" };
default:
return null;
}
}
const STEPPING: ReadonlySet<string> = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]);
type Range = readonly [start: number, end: number];
/** Where each part that is not a literal lies in the text the parts join to. */
function rangesOf(parts: readonly Part[]): Array<{ type: string; range: Range }> {
const ranges: Array<{ type: string; range: Range }> = [];
let offset = 0;
for (const part of parts) {
if (part.type !== "literal") ranges.push({ type: part.type, range: [offset, offset + part.text.length] });
offset += part.text.length;
}
return ranges;
}
/**
* The part a selection lies within, if it lies within one. A caret at the very
* end of the text is in none: it is where a press beside the text puts it, not
* a choice of the last part.
*/
function partAt(parts: readonly Part[], start: number, end: number, length: number): string | undefined {
if (start === end && start === length) return undefined;
return rangesOf(parts).find(({ range }) => range[0] <= start && end <= range[1])?.type;
}
export function useField<T>({
type,
value: valueProp,
defaultValue = null,
onValueChange,
onValueCommit,
required = false,
multiline = false,
onInvalidChange,
selectOnFocus = false,
}: FieldOptions<T>): Field<T> {
const [value, setValue] = useControllableState<T | null>(valueProp, defaultValue, onValueChange);
// What is being typed, or null when the field shows its value.
const [draft, setDraft] = useState<string | null>(null);
const [invalid, setInvalid] = useState<string | null>(null);
const [constrained, setConstrained] = useState(false);
const text = draft ?? (value === null ? "" : type.format(value));
const input = useRef<FieldElement>(null);
// The part to select once the stepped value is shown.
const stepped = useRef<string>(undefined);
const [steps, countStep] = useReducer((count: number) => count + 1, 0);
// Whether focus has just arrived, and by a press: its release may select the text.
const arriving = useRef(false);
// The value a gesture in flight began on.
const gesture = useRef<{ readonly from: T | null }>(null);
useLayoutEffect(() => {
const part = stepped.current;
stepped.current = undefined;
if (part === undefined || value === null || type.parts === undefined) return;
const found = rangesOf(type.parts(value)).find((candidate) => candidate.type === part);
if (found !== undefined) input.current?.setSelectionRange(found.range[0], found.range[1]);
// `steps` is not read: a step that leaves the value where it was is still a step.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [steps, value, type]);
const ref = useCallback((node: FieldElement | null) => {
input.current = node;
}, []);
const mark = useCallback(
(reason: string | null) => {
setInvalid(reason);
if (reason !== invalid) onInvalidChange?.(reason);
},
[invalid, onInvalidChange],
);
const keep = useCallback(
(next: T | null, typed = false): T | null => {
const kept = next === null || type.constrain === undefined ? next : type.constrain(next);
setValue(kept);
setDraft(null);
mark(null);
// Told apart by how they are shown, so that rounding to the precision is not counted.
setConstrained(typed && next !== null && kept !== null && type.format(kept) !== type.format(next));
return kept;
},
[type, setValue, mark],
);
// What was typed is a gesture that ends as it is read.
const settle = useCallback(
(kept: T | null) => {
if (!Object.is(kept, value)) onValueCommit?.(kept);
},
[value, onValueCommit],
);
const read = useCallback(
(typed: string): boolean => {
// Text that is the value as shown is the value: reading it again could only lose precision.
if (value !== null && typed === type.format(value)) {
setDraft(null);
mark(null);
return true;
}
const result = type.parse(typed);
if (result.ok) settle(keep(result.value, true));
else if (result.reason === "empty" && !required) settle(keep(null));
else {
setDraft(typed);
mark(result.reason);
return false;
}
return true;
},
[value, type, keep, settle, required, mark],
);
const commit = useCallback(() => (draft === null ? true : read(draft)), [draft, read]);
/** One step, from what is typed when it reads as a value; `selection` is where the caret is, to name a part. */
const stepBy = (step: Step, selection?: readonly [start: number | null, end: number | null]): void => {
if (type.step === undefined) return;
const typed = draft === null ? null : type.parse(draft);
const from = typed?.ok ? typed.value : value;
gesture.current ??= { from: value };
countStep();
if (from === null) {
// An empty field's first step is to where stepping starts.
if (type.origin !== undefined) keep(type.origin);
return;
}
// The caret names a part only while the text is the value as formatted.
stepped.current =
selection !== undefined &&
type.parts !== undefined &&
text === type.format(from) &&
selection[0] !== null &&
selection[1] !== null
? partAt(type.parts(from), selection[0], selection[1], text.length)
: undefined;
keep(type.step(from, { ...step, part: stepped.current ?? step.part }));
};
const end = (): void => {
const began = gesture.current;
if (began === null) return;
gesture.current = null;
if (!Object.is(value, began.from)) onValueCommit?.(value);
};
const cancel = (): void => {
const began = gesture.current;
if (began === null) return;
gesture.current = null;
setValue(began.from);
setDraft(null);
mark(null);
setConstrained(false);
};
// What is called from outside — in a drag, from a timer — acts on the field as
// it is now, whichever render the caller last saw.
const now = useRef({ stepBy, end, cancel });
useLayoutEffect(() => {
now.current = { stepBy, end, cancel };
});
const stepOutside = useCallback((step: Step) => {
const node = input.current;
if (node !== null && (node.readOnly || node.disabled)) return;
// Shown at once: the next step of a drag starts from this one, not from what was last drawn.
flushSync(() => now.current.stepBy(step));
}, []);
const endOutside = useCallback(() => now.current.end(), []);
const cancelOutside = useCallback(() => flushSync(() => now.current.cancel()), []);
// A value given from outside while nothing is being typed here replaces what
// was left unread: it is another value's field now.
const shown = useRef(value);
useEffect(() => {
if (Object.is(shown.current, value)) return;
shown.current = value;
if (draft !== null && document.activeElement !== input.current) {
// oxlint-disable-next-line react/set-state-in-effect -- answering a value owned outside
setDraft(null);
mark(null);
}
}, [value, draft, mark]);
// A form that is reset shows the field as it began.
useEffect(() => {
const form = input.current?.form;
if (form == null) return undefined;
const reset = () => {
setDraft(null);
mark(null);
if (valueProp === undefined) setValue(defaultValue);
};
form.addEventListener("reset", reset);
return () => form.removeEventListener("reset", reset);
}, [mark, valueProp, setValue, defaultValue]);
return {
value,
text,
invalid,
editing: draft !== null,
constrained,
commit,
set: (next) => settle(keep(next)),
step: stepOutside,
end: endOutside,
cancel: cancelOutside,
inputProps: {
ref,
value: text,
"aria-invalid": invalid === null ? undefined : true,
"data-constrained": constrained ? "" : undefined,
onChange(event) {
setConstrained(false);
// Text that arrives without focus was not typed — a browser filled it in —
// and no blur will follow to commit it.
if (document.activeElement !== event.target) read(event.target.value);
else setDraft(event.target.value);
},
onKeyDown(event) {
// Keys pressed during a composition are the input method's.
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
const target = event.currentTarget;
if (event.key === "Enter") {
if (multiline && !event.metaKey && !event.ctrlKey) return;
// Committed now, within the press, so that a form submitted by this
// Enter is sent the value just typed — and not submitted at all if the
// text cannot be read. Whatever contains the field can still claim the key.
let readable = true;
flushSync(() => {
readable = commit();
});
if (!readable) event.preventDefault();
return;
}
if (event.key === "Escape") {
if (draft === null && invalid === null) return;
event.preventDefault();
event.stopPropagation();
setDraft(null);
mark(null);
return;
}
if (target.readOnly || target.disabled) return;
const { selectionStart, selectionEnd } = target;
const parts = value !== null && draft === null ? type.parts?.(value) : undefined;
// With one part selected, the arrows to either side move from part to part.
if (
parts !== undefined &&
(event.key === "ArrowLeft" || event.key === "ArrowRight") &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey &&
selectionStart !== selectionEnd
) {
const ranges = rangesOf(parts);
const at = ranges.findIndex(
({ range }) => range[0] === selectionStart && range[1] === selectionEnd,
);
const next = at === -1 ? undefined : ranges[at + (event.key === "ArrowRight" ? 1 : -1)];
if (next !== undefined) {
event.preventDefault();
target.setSelectionRange(next.range[0], next.range[1]);
}
return;
}
const step = stepFor(event);
if (step === null || type.step === undefined) return;
event.preventDefault();
stepBy(step, [selectionStart, selectionEnd]);
},
onKeyUp(event) {
// A key held down has stepped many times, and is one gesture.
if (STEPPING.has(event.key)) end();
},
onFocus(event) {
// Told apart from Tab, which selects the text itself, by the press still being down.
arriving.current = selectOnFocus && event.currentTarget.matches(":active");
},
onPointerUp(event) {
if (!arriving.current) return;
arriving.current = false;
const target = event.currentTarget;
// A finger places the caret; and a drag that selected something is left alone.
if (event.pointerType !== "touch" && target.selectionStart === target.selectionEnd) target.select();
},
onBlur() {
arriving.current = false;
// The release of a key held as focus left will be heard elsewhere, if at all.
end();
// Another window took focus, not another control: nothing has been left.
if (!document.hasFocus()) return;
commit();
},
},
};
}