Form field
A label, an input, a hint and the input's error, stacked.
On this page
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { Select } from "@bosunkit/components/select";
import { Anchor, MapPin } from "lucide-react";
import {
useId,
useState,
type ChangeEvent,
type CSSProperties,
type Dispatch,
type ReactElement,
type SetStateAction,
type SubmitEvent,
} from "react";
export const description: string =
"Arming the anchor watch: required, numeric, read-only and disabled fields with hints, units and an icon, a select " +
"named by its field's label, and the errors a press of Arm leaves under the fields that need a fix.";
const MINIMUM_RADIUS_METERS: number = 20;
const MAXIMUM_RADIUS_METERS: number = 200;
const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: "var(--space-sm)" };
interface Errors {
readonly radius?: string | undefined;
readonly phone?: string | undefined;
}
function validate(radius: string, phone: string): Errors {
const meters: number = Number(radius);
const digits: number = phone.replace(/\D/g, "").length;
return {
radius:
radius.trim() === "" || Number.isNaN(meters) || meters < MINIMUM_RADIUS_METERS || meters > MAXIMUM_RADIUS_METERS
? `The anchor watch takes a swing radius from ${String(MINIMUM_RADIUS_METERS)} to ` +
`${String(MAXIMUM_RADIUS_METERS)} m; ${radius.trim() === "" ? "nothing" : `${radius} m`} was entered. ` +
"Enter a radius in that range."
: undefined,
phone:
digits < 8
? "The alarm phone number has fewer than 8 digits. Enter it with the country code, such as +33."
: undefined,
};
}
export default function Showcase(): ReactElement {
const formId: string = useId();
const contactId: string = useId();
const [radius, setRadius]: [string, Dispatch<SetStateAction<string>>] = useState<string>("350");
const [phone, setPhone]: [string, Dispatch<SetStateAction<string>>] = useState<string>("+33 6 12 34 56 78");
const [contact, setContact]: [string, Dispatch<SetStateAction<string>>] = useState<string>("marta");
// The example opens as the crew see the form after one press of Arm with a radius out of range.
const [errors, setErrors]: [Errors, Dispatch<SetStateAction<Errors>>] = useState<Errors>((): Errors =>
validate("350", "+33 6 12 34 56 78"),
);
const [armed, setArmed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function arm(event: SubmitEvent<HTMLFormElement>): void {
event.preventDefault();
const found: Errors = validate(radius, phone);
setErrors(found);
setArmed(found.radius === undefined && found.phone === undefined);
}
return (
<form
id={formId}
onSubmit={arm}
noValidate
style={{
width: "100%",
maxWidth: 560,
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "var(--space-md)",
}}
>
<FormField label="Anchorage" required>
<Input defaultValue="Baie de Morgat, off the lifeboat slip" />
</FormField>
<FormField label="Drop position">
<Input readOnly value="48°13.42′N 004°29.87′W" icon={<MapPin />} />
</FormField>
<FormField
label="Swing radius"
required
hint="45 m of chain out in 9 m of water gives about 50 m."
error={errors.radius}
>
<Input
type="number"
inputMode="decimal"
min={MINIMUM_RADIUS_METERS}
max={MAXIMUM_RADIUS_METERS}
unit="m"
value={radius}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setRadius(event.target.value);
setArmed(false);
}}
/>
</FormField>
<FormField
label="Depth at drop"
hint="The depth sounder has sent no reading since 01:58 UTC, so this fills in when it does."
>
<Input disabled placeholder="No reading" value="" unit="m" />
</FormField>
<FormField label="Who hears the alarm" htmlFor={contactId}>
<Select.Root value={contact} onValueChange={(next: string | null): void => { (setContact)(next ?? ""); }}>
<Select.Trigger id={contactId}>
<Select.Value />
</Select.Trigger>
<Select.Portal><Select.Positioner alignItemWithTrigger={true} ><Select.Popup ><Select.List>
<Select.Item value="marta">Marta Lindqvist, on watch</Select.Item>
<Select.Item value="anneke">Anneke de Vries, skipper</Select.Item>
<Select.Item value="all">Everyone aboard</Select.Item>
</Select.List></Select.Popup></Select.Positioner></Select.Portal>
</Select.Root>
</FormField>
<FormField label="Alarm phone" required error={errors.phone}>
<Input
type="tel"
autoComplete="tel"
value={phone}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setPhone(event.target.value);
setArmed(false);
}}
/>
</FormField>
<div style={{ ...ROW, gridColumn: "1 / -1" }}>
<Button variant="primary" type="submit" form={formId}>
<Anchor size={14} aria-hidden="true" />
Arm anchor watch
</Button>
{armed && <Badge variant="green">Armed at {radius} m</Badge>}
</div>
</form>
);
}
A form field names an input and carries its hint and its error. The label sits above the input as a <label>, the
hint under it, and the error under the hint, in the alarm color. The error is a role="alert", so a screen reader
announces it when it appears. Say what is wrong and how to fix it, in one sentence.
A kit input inside the field takes everything from it: the label's id, so the label names
it and a press on the label focuses it; the hint and the error as its description; aria-required when the field is
required; and aria-invalid while the field shows an error. Another control takes the field's label through
htmlFor, set to the control's own id.
Examples
Field
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string = "A label above an input.";
export default function Field(): ReactElement {
return (
<FormField label="Vessel name">
<Input defaultValue="Althea" />
</FormField>
);
}
With error
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"An error under the input says what is wrong and how to fix it; it is announced when it appears.";
export default function WithError(): ReactElement {
return (
<FormField label="Vessel name" error="Enter the vessel's name.">
<Input defaultValue="" />
</FormField>
);
}
Required
required puts "Required" after the label, in the crew's language, and marks a kit input aria-required, so
assistive technology announces it. The word is hidden from assistive technology, so the input's name stays the
field's name.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"A required field says so after its label, and the input is marked aria-required so assistive technology says it.";
export default function Required(): ReactElement {
return (
<FormField label="Vessel name" required>
<Input placeholder="As on the registration papers" />
</FormField>
);
}
With hint
hint sits under the input and is tied to a kit input through aria-describedby, so a screen reader reads it with
the field. Keep the hint to one line.
Nine digits, from the vessel's radio license.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"A hint under the input, which the input reads with its name through aria-describedby.";
export default function WithHint(): ReactElement {
return (
<FormField label="MMSI" hint="Nine digits, from the vessel's radio license.">
<Input inputMode="numeric" maxLength={9} defaultValue="244630123" />
</FormField>
);
}
Number with a unit
Give the input its unit: it sits inside the input's right edge and is read after the value, so the field's name
need not repeat it. A value shown to the crew keeps its unit in every place it appears.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"A number with its unit inside the input's right edge, so the crew never have to guess whether it is meters or feet.";
export default function NumberWithUnit(): ReactElement {
return (
<FormField label="Safety depth">
<Input type="number" inputMode="decimal" min={0} step={0.1} defaultValue="2.5" unit="m" />
</FormField>
);
}
Read-only
Use readOnly for a value the crew may need to copy. It stays focusable and is read out with its value.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"A read-only field shows a value the crew can select and copy but not change; it stays in the tab order.";
export default function ReadOnly(): ReactElement {
return (
<FormField label="Vessel ID">
<Input readOnly defaultValue="althea-hanse-460" />
</FormField>
);
}
Disabled
Use disabled for a value that cannot be changed right now, and say why in the hint. A disabled input is skipped by
the keyboard, so the hint is the only place a screen reader finds the reason when it reads the form.
Read from the engine bus. It cannot be edited while the bus is connected.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import type { ReactElement } from "react";
export const description: string =
"A disabled field is dimmed and skipped by the keyboard; the hint says why it cannot be changed.";
export default function Disabled(): ReactElement {
return (
<FormField label="Engine hours" hint="Read from the engine bus. It cannot be edited while the bus is connected.">
<Input disabled defaultValue="1,284" unit="hr" />
</FormField>
);
}
Password
A password goes in a password input inside the field. Its purpose writes the
autocomplete that matches the task, current-password when the crew enter a password they already have and
new-password when they choose one, which a password manager uses to fill or generate the value. Its show button
helps on a small touch screen, where a mistyped password is hard to spot; the button is named for what pressing it
does next and carries no aria-pressed.
Code
import { FormField } from "@bosunkit/components/form-field";
import { PasswordInput } from "@bosunkit/components/password-input";
import type { ReactElement } from "react";
export const description: string =
"A password inside a form field: the field's label names it, and its show button and autocomplete come with it.";
export default function Password(): ReactElement {
return (
<FormField label="Wi-Fi password">
<PasswordInput purpose="current" defaultValue="windward-12" />
</FormField>
);
}
New password
Ask for a new password once, in a password input with purpose="new": its show
button lets the crew read what they typed, so a second field adds typing and no check. State the length rule in the
error with the count the crew entered.
- At least 8 charactersnot yet
- Not a common or leaked passwordchecking
Code
import { FormField } from "@bosunkit/components/form-field";
import { PasswordInput } from "@bosunkit/components/password-input";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"One new-password field with a show button and no second field; it says how many characters are missing.";
const MINIMUM_LENGTH: number = 8;
export default function NewPassword(): ReactElement {
const [password, setPassword]: [string, Dispatch<SetStateAction<string>>] = useState<string>("windwar");
const tooShort: boolean = password !== "" && password.length < MINIMUM_LENGTH;
return (
<FormField
label="New password"
error={
tooShort
? `The password has ${String(password.length)} characters. Use at least ${String(MINIMUM_LENGTH)}.`
: undefined
}
>
<PasswordInput purpose="new" minLength={MINIMUM_LENGTH} value={password} onValueChange={setPassword} />
</FormField>
);
}
Saves immediately
Save a field as it changes, with no button, when the setting takes effect at once and can be changed back. Save on
blur or after a short pause in typing, show the save chip beside the field, and put the reason for a failed save in the
field's error. The value stays in the field after a failure so the crew can correct it. The example rejects depths
under 1 m to show that path; type 0.5 and wait. For a whole settings row with the same behavior, see
setting control.
Code
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { SaveChip, type SaveState } from "@bosunkit/components/setting-control";
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"A field with no save button: it saves 600 ms after the last keystroke or on blur, shows the save chip, and puts a failed save's reason in the field.";
const DEBOUNCE_MILLISECONDS: number = 600;
const SAVE_MILLISECONDS: number = 400;
const MINIMUM_METERS: number = 1;
// The example's stand-in for the write: it takes a moment and rejects depths under the minimum.
function save(value: string): Promise<void> {
return new Promise<void>((resolve: () => void, reject: (reason: Error) => void): void => {
window.setTimeout((): void => {
if (Number(value) < MINIMUM_METERS) {
reject(
new Error(
`Couldn't save the safety depth. The minimum is ${String(MINIMUM_METERS)} m; enter ${String(MINIMUM_METERS)} or more.`,
),
);
return;
}
resolve();
}, SAVE_MILLISECONDS);
});
}
export default function SavesImmediately(): ReactElement {
const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState<string>("2.5");
const [state, setState]: [SaveState, Dispatch<SetStateAction<SaveState>>] = useState<SaveState>("idle");
const [error, setError]: [string | undefined, Dispatch<SetStateAction<string | undefined>>] = useState<
string | undefined
>(undefined);
const timer: RefObject<number | undefined> = useRef<number | undefined>(undefined);
const attempt: RefObject<number> = useRef<number>(0);
const lastSaved: RefObject<string> = useRef<string>("2.5");
// A pending debounce must not fire into an unmounted example.
useEffect((): (() => void) => {
return (): void => {
window.clearTimeout(timer.current);
attempt.current += 1;
};
}, []);
function commit(next: string): void {
window.clearTimeout(timer.current);
if (next === lastSaved.current) {
return;
}
attempt.current += 1;
const thisAttempt: number = attempt.current;
setState("saving");
save(next).then(
(): void => {
if (thisAttempt !== attempt.current) {
return;
}
lastSaved.current = next;
setError(undefined);
setState("saved");
timer.current = window.setTimeout((): void => {
setState("idle");
}, 2000);
},
(reason: unknown): void => {
if (thisAttempt !== attempt.current) {
return;
}
setError(reason instanceof Error ? reason.message : "Couldn't save the safety depth. Try again.");
setState("error");
},
);
}
return (
<FormField label="Safety depth" error={error}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Input
type="number"
inputMode="decimal"
step={0.1}
unit="m"
value={value}
aria-invalid={state === "error"}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
const next: string = event.target.value;
setValue(next);
window.clearTimeout(timer.current);
timer.current = window.setTimeout((): void => {
commit(next);
}, DEBOUNCE_MILLISECONDS);
}}
onBlur={(): void => {
commit(value);
}}
/>
<SaveChip state={state} />
</div>
</FormField>
);
}
Group of fields
Use a button for anything that changes several values together or cannot be undone. Stack the fields with the medium gap and put the button last. The error appears under the field that needs the fix, after the crew press the button, not while they type.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import {
useState,
type ChangeEvent,
type Dispatch,
type SubmitEvent,
type ReactElement,
type SetStateAction,
} from "react";
export const description: string =
"Three fields stacked with the kit's medium gap, saved together by one button; the error appears under the field that needs the fix.";
const MMSI_DIGITS: number = 9;
export default function FormGroup(): ReactElement {
const [name, setName]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Althea");
const [callSign, setCallSign]: [string, Dispatch<SetStateAction<string>>] = useState<string>("PD1234");
const [mmsi, setMmsi]: [string, Dispatch<SetStateAction<string>>] = useState<string>("2446301");
const [submitted, setSubmitted]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const mmsiError: string | undefined =
submitted && mmsi.length !== MMSI_DIGITS
? `The MMSI has ${String(mmsi.length)} digits and needs ${String(MMSI_DIGITS)}. Check the number on the radio license.`
: undefined;
return (
<form
noValidate
style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}
onSubmit={(event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
setSubmitted(true);
}}
>
<FormField label="Vessel name">
<Input
autoComplete="off"
value={name}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setName(event.target.value);
}}
/>
</FormField>
<FormField label="Call sign">
<Input
autoComplete="off"
autoCapitalize="characters"
value={callSign}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setCallSign(event.target.value);
}}
/>
</FormField>
<FormField label="MMSI" error={mmsiError}>
<Input
inputMode="numeric"
autoComplete="off"
value={mmsi}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setMmsi(event.target.value);
}}
/>
</FormField>
<div>
<Button type="submit" variant="primary">
Save vessel details
</Button>
</div>
</form>
);
}
Motion
By default the error line rises in under the input when an error appears, fades its new words in when the error
changes, and fades out when the value is fixed; an error there on the first render does not move. motion takes the
kit's motion prop, and motion={false} shows and hides the error at once. The error is
announced and readable from its first frame, and under reduced motion it only fades.
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Press Check with the drafts empty: by default the error rises in under the first field, announced at once, and " +
"fades out once a draft is entered and checked; the second field, with motion={false}, shows and hides it at once.";
function Draft({ label, motion }: { label: string; motion: boolean }): ReactElement {
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [error, setError]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-sm)" }}>
<FormField label={label} error={error} {...(motion ? {} : { motion: false })}>
<Input
aria-label={label}
inputMode="decimal"
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.target.value);
}}
/>
</FormField>
<Button
size="sm"
onClick={(): void => {
setError(draft.trim() === "" ? "Enter the draft in meters, such as 2.1." : "");
}}
>
Check
</Button>
</div>
);
}
export default function Motion(): ReactElement {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)" }}>
<Draft label="Draft" motion />
<Draft label="Draft, without motion" motion={false} />
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | The field's name, shown above it in a <label> that names the input. |
| htmlFor | string | undefined | – | The id of the control the label names. A kit Input inside the field takes a generated id, so it is needed only
for another control, or an Input that brings its own id. |
| hint | ReactNode | undefined | – | A line under the input that says what to enter or where the value comes from. A kit Input reads it with its
name, through aria-describedby. |
| required | boolean | undefined | false | The field must be filled: the label says "Required" in the crew's language, and a kit Input is marked
aria-required. The input keeps its name; the word is for sighted crew. |
| error | string | undefined | – | What is wrong with the value and how to fix it; announced when it appears. A kit Input is then marked
aria-invalid and reads the error with its name. |
| appearance | "plain" | "outlined" | undefined | "plain" | Label placement; outlined draws the same field semantics on an outline. |
| childrenrequired | ReactNode | – | The input. |
| className | string | undefined | – | Classes added to the field's own. |
| testId | string | undefined | – | Becomes data-testid on the field. |
| motion | MotionProp | undefined | – | How the error line moves. By default an error that appears rises in, new words fade in when it changes, and it
fades out when the value is fixed; an error there on the first render does not move. A preset (fade, rise,
scale) or { enter, exit, change } chooses; false turns it off. The error is announced and readable from its
first frame. |
Related
- Input: the text input a field usually wraps, with its unit and icon.
- Setting control: the settings row that saves a field as it changes.
- Switch: the control for a setting that is on or off.
- Accessibility: accessible names and announced errors.