Forms
Every sized control is exactly its size’s control token tall, in every preset and density, so an input, a select, and a button of one size line up. Choice controls sit on a line of text.
Field
label · hint · error · required — wires one control
Shown to everyone you invite.
That handle is taken.
Letters, numbers, and dashes.
Children is a function that receives exactly the attributes the control must carry: its id, and aria-describedby, aria-invalid, and required when they apply. Spread them and the label, hint, and error are wired; there is nothing to remember.
The error is announced first, before the hint, and the required mark is visual only: required on the control is the announcement.
Sourcecomponents/forms/field/doc.ts · components/forms/field/field.tsx · components/forms/label/doc.ts · components/forms/label/label.tsx
components/forms/field/doc.ts
/**
* Field — a label, a hint, and an error around one control.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label content, REQUIRED
* hint? content — always shown when given
* error? content — a validation message
* required? boolean
* children a FUNCTION, called with the props the control must carry
*
* The function receives exactly the attributes to spread onto the control:
*
* id the identifier the label points at
* aria-describedby? the error and hint identifiers, or absent
* aria-invalid? true, or absent
* required? true, or absent
*
* # Behaviour
*
* R1 The label is associated with the control by identifier. Identifiers are
* unique per instance: two Fields with the same label never collide.
* R2 `aria-describedby` names the error FIRST and the hint second, because a
* reader announces them in that order and the error is more urgent. With
* neither, the attribute is absent — never empty.
* R3 `aria-invalid` and `required` are present only when true. Absent, never
* false: `aria-invalid="false"` is a different announcement.
* R4 A required field shows a mark after its label, hidden from assistive
* technology; `required` on the control is the announcement.
* R5 The error renders directly under the control, the hint under that.
*
* # Deliberately absent
*
* The control's value, name, and change handler, and validation. Field owns
* the three things around a control, not its data; the error is a string the
* caller supplies from wherever it comes from.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Why children is a function: a Field taking a node would have to reach into
* the child to wire it, which breaks silently when the control is nested one
* level deeper. A function cannot be forgotten the same way — there is nothing
* to render without calling it — and its argument arrives already named as the
* attributes it becomes. Svelte uses a snippet with a parameter for the same
* reason. Identifiers come from the framework's own generator (useId,
* $props.id) because they must match between server render and hydration.
*/
export {};components/forms/field/field.tsx
import { useId, type ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
import { Label } from "../label";
import styles from "./field.module.css";
/** Exactly the attributes the control must carry, named as the attributes
* they become, so a caller spreads them and is done. */
export type FieldControlProps = {
id: string;
"aria-describedby"?: string;
"aria-invalid"?: true;
required?: true;
};
export type FieldProps = {
label: ReactNode;
hint?: ReactNode;
error?: ReactNode;
required?: boolean;
className?: string;
/** Called with the props the control must carry; see doc.ts. */
children: (control: FieldControlProps) => ReactNode;
};
export function Field({
label,
hint,
error,
required,
className,
children,
}: FieldProps) {
// Unique per instance and stable across a server render and hydration.
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
// Error first: it is announced first and it is the more urgent.
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn(styles.root, className)}>
<Label htmlFor={id}>
{label}
{/* Visual only: `required` on the control is the announcement. */}
{required ? (
<span className={styles.required} aria-hidden="true">
*
</span>
) : null}
</Label>
{children({
id,
"aria-describedby": describedBy,
// Absent, never false: aria-invalid="false" is its own announcement.
"aria-invalid": error ? true : undefined,
required: required || undefined,
})}
{error ? (
<p id={errorId} className={styles.error}>
{error}
</p>
) : null}
{hint ? (
<p id={hintId} className={styles.hint}>
{hint}
</p>
) : null}
</div>
);
}components/forms/label/doc.ts
/**
* Label — the name of a control.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* variant? "field" | "inline" default "field"
* …every attribute of a label, and a ref
*
* # Behaviour
*
* R1 Clicking the label focuses or activates its control, including controls
* the platform does not associate with a label natively (a checkbox,
* radio, switch, or select trigger rendered as a button).
* R2 `field` names a field and sits above its control, in strong weight.
* R3 `inline` wraps a choice control and its text on one line, in regular
* weight, with a pointer cursor; when the control inside is disabled, the
* whole label fades and shows a not-allowed cursor.
* R4 Text is `--text-control`, the same size as the text inside controls.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The one labelling implementation in the tree, so R1's click-to-focus exists
* once. R3's fade reads the disabled state of the control inside with `:has`.
*/
export {};components/forms/label/label.tsx
"use client";
import * as Primitive from "@radix-ui/react-label";
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import { labelVariants, type LabelVariants } from "./label.variants";
export type LabelProps = ComponentPropsWithRef<typeof Primitive.Root> &
LabelVariants;
/** The one labelling implementation. The primitive adds click-to-focus for
* controls the platform does not associate natively (the checkbox, radio,
* switch, and select triggers are buttons), which should exist once. */
export function Label({ variant, className, ...props }: LabelProps) {
return (
<Primitive.Root
{...props}
className={cn(labelVariants({ variant }), className)}
/>
);
}Fieldset
legend · hint · error — one question, several controls
Sourcecomponents/forms/fieldset/doc.ts · components/forms/fieldset/fieldset.tsx · components/forms/fieldset/fieldset.module.css
components/forms/fieldset/doc.ts
/**
* Fieldset — a group of controls that answer one question.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* legend content, REQUIRED — the question
* hint? content
* error? content
* children the controls
* …every attribute of a fieldset, and a ref
*
* # Behaviour
*
* R1 The legend is announced before every control inside the group.
* R2 The hint follows the legend (it qualifies the question); the error
* follows the controls (it is about the answer). The group is described by
* the error first, then the hint, as with Field.
* R3 No border, padding, or margin: a group sits in a form like any other
* field. Put it on a surface when it needs a frame.
* R4 It never forces its parent wider than the parent allows.
*
* # Use it for
*
* A radio group, a set of related checkboxes, a date split across inputs —
* any time one question has several controls.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The platform fieldset defaults to min-content width, which breaks grids;
* R4 is `min-width: 0`. The platform's own border, padding, and legend
* position are reset for R3. Unlike Flover's Fieldset, it is unframed by
* default, because Bento's presets already frame surfaces with Box.
*/
export {};components/forms/fieldset/fieldset.tsx
import { useId, type ComponentPropsWithRef, type ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./fieldset.module.css";
export type FieldsetProps = Omit<ComponentPropsWithRef<"fieldset">, "title"> & {
/** Names the group; announced before every control inside it. */
legend: ReactNode;
hint?: ReactNode;
error?: ReactNode;
children: ReactNode;
};
/** A group of controls that answer one question. The counterpart to Field: a
* single control receives the wiring, a group keeps it on the container, so
* children are plain nodes here. */
export function Fieldset({
legend,
hint,
error,
className,
children,
...props
}: FieldsetProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
return (
<fieldset
{...props}
aria-describedby={describedBy}
data-invalid={error ? "" : undefined}
className={cn(styles.root, className)}
>
<legend className={styles.legend}>{legend}</legend>
{/* A hint qualifies the question and belongs with it. */}
{hint ? (
<p id={hintId} className={styles.hint}>
{hint}
</p>
) : null}
<div className={styles.body}>{children}</div>
{/* An error is about the answer and belongs after it. */}
{error ? (
<p id={errorId} className={styles.error}>
{error}
</p>
) : null}
</fieldset>
);
}components/forms/fieldset/fieldset.module.css
@layer primitive {
.root {
display: grid;
align-content: start;
/* A fieldset defaults to min-content width and breaks grids. */
min-width: 0;
margin: 0;
padding: 0;
gap: var(--space-4);
border: 0;
}
/* A legend is laid out outside the fieldset's grid, so the gap does not
reach it; its own margin sets the space below it. */
.legend {
margin-bottom: var(--space-3);
padding: 0;
color: var(--ink);
font-size: var(--text-control, var(--text-13));
font-weight: var(--weight-strong);
}
.body {
display: grid;
gap: var(--space-4);
}
.hint,
.error {
font-size: var(--text-12);
line-height: var(--leading-snug);
}
.hint {
color: var(--ink-3);
}
.error {
color: var(--crit);
}
}Input and Textarea
size · mono · read-only · invalid · disabled
Sourcecomponents/forms/input/doc.ts · components/forms/input/input.tsx · components/forms/input/textarea.tsx · components/forms/input/input.variants.ts · components/forms/input/input.module.css
components/forms/input/doc.ts
/**
* Input and Textarea — text the user types.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* size? "sm" | "md" | "lg" default "md"
* mono? boolean default false
* …every attribute of an input except its native `size`, and a ref
*
* # Behaviour
*
* R1 Renders one native input that fills its parent's width.
* R2 Height is exactly the size's control token (forms R1), so an Input and a
* Button of the same size line up. Inline padding is `--space-4`,
* `--space-5`, or `--space-6`.
* R3 `mono` sets the monospace family with tabular figures, for identifiers,
* keys, and codes.
* R4 The border darkens on hover. A read-only input sits on `--surface-sunk`
* so it does not look editable.
* R5 `aria-invalid="true"` turns the border and the focus ring `--crit`, and
* the invalid border holds while the pointer is over the field.
* R6 The value is the caller's: uncontrolled, or controlled through the
* framework's usual binding.
*
* # Textarea
*
* The same control with rows (default 4). It shares every rule above except
* R2: its height follows its rows (at least two md control heights) and it can
* be resized vertically; the size sets its padding.
*
* # Deliberately absent
*
* A label, hint, or error (those belong to Field), and prefix and suffix
* slots.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The native `size` attribute (a width in characters) is omitted so `size`
* can mean the control size. Hover is written inside `:where()` so it adds no
* specificity and R5's invalid border wins over it. React takes `value` and
* `onChange` as usual; Svelte's `value` is `$bindable`, for `bind:value`.
*/
export {};components/forms/input/input.tsx
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import { inputVariants, type InputVariants } from "./input.variants";
export type InputProps = Omit<ComponentPropsWithRef<"input">, "size"> &
InputVariants;
export function Input({ className, size, mono, ...props }: InputProps) {
return (
<input
{...props}
className={cn(inputVariants({ size, mono }), className)}
/>
);
}components/forms/input/textarea.tsx
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import { inputVariants, type InputVariants } from "./input.variants";
export type TextareaProps = ComponentPropsWithRef<"textarea"> & InputVariants;
/** The same control with rows. It shares Input's variant map, so the two
* cannot drift apart in border, focus, hover, or invalid styling. */
export function Textarea({
className,
size,
mono,
rows = 4,
...props
}: TextareaProps) {
return (
<textarea
{...props}
rows={rows}
className={cn(inputVariants({ size, mono, multiline: true }), className)}
/>
);
}components/forms/input/input.variants.ts
import { cva, type VariantProps } from "class-variance-authority";
import styles from "./input.module.css";
export const inputVariants = cva(styles.root, {
variants: {
size: { sm: styles.sm, md: styles.md, lg: styles.lg },
mono: { true: styles.mono },
// Set by Textarea only.
multiline: { true: styles.multiline },
},
defaultVariants: { size: "md" },
});
export type InputVariants = Omit<
VariantProps<typeof inputVariants>,
"multiline"
>;components/forms/input/input.module.css
@layer primitive {
.root {
box-sizing: border-box;
width: 100%;
/* Height comes from the size token, never from font metrics, so an input
and a button of the same size line up in a row. */
padding-block: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-2);
background: var(--surface-panel);
color: var(--ink);
font: inherit;
font-size: var(--text-control, var(--text-13));
line-height: normal;
transition:
border-color var(--dur-2) var(--ease),
background-color var(--dur-2) var(--ease);
}
.sm {
height: var(--control-sm);
padding-inline: var(--space-4);
}
.md {
height: var(--control-md);
padding-inline: var(--space-5);
}
.lg {
height: var(--control-lg);
padding-inline: var(--space-6);
}
/* Textarea: the size sets padding and text, and the height follows the rows
instead of the control token. Written after the sizes so it wins. */
.multiline {
height: auto;
min-height: calc(var(--control-md) * 2);
padding-block: var(--space-4);
line-height: var(--leading-body);
resize: vertical;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.root::placeholder {
color: var(--ink-3);
}
/* :where keeps hover at the specificity of .root, so the invalid border
below still wins while the pointer is over the field. */
.root:where(:hover:not(:disabled, :focus-visible)) {
border-color: var(--line-heavy);
}
.root:read-only:not(:disabled) {
background: var(--surface-sunk);
}
.root:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.root[aria-invalid="true"] {
border-color: var(--crit);
}
.root[aria-invalid="true"]:focus-visible {
outline-color: var(--crit);
}
.root:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}Checkbox
unchecked · checked · indeterminate · invalid · disabled
Indeterminate is a state, not a style. It comes from the data — some of a group are selected — and activating it checks it.
Sourcecomponents/forms/checkbox/doc.ts · components/forms/checkbox/checkbox.tsx · components/forms/checkbox/checkbox.module.css
components/forms/checkbox/doc.ts
/**
* Checkbox — one yes/no choice that is submitted with a form.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* checked? true | false | "indeterminate" (controlled)
* defaultChecked? the same (uncontrolled)
* onCheckedChange? called with the new state
* disabled?, required?, name?, value?
*
* # Behaviour
*
* R1 A 16px box: bordered when unchecked, filled with `--fill` and a tick when
* checked, filled with a dash when indeterminate.
* R2 Indeterminate is a state from the data ("some of these are selected"),
* never a style choice. Activating it checks it.
* R3 Space toggles it; it is announced as a checkbox with its state.
* R4 `aria-invalid="true"` turns the border `--crit`.
* R5 Inside a form it submits `value` (default "on") under `name` when
* checked, like a native checkbox.
* R6 It is labelled by a Label: `inline` around it, or a Field around it.
*
* # Checkbox or Switch?
*
* A checkbox is part of a form that is submitted. A switch takes effect the
* moment it changes.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The primitive renders a button with role="checkbox" and, inside a form, a
* hidden native input so R5 holds. Both glyphs render and the state attribute
* hides the wrong one, so they can never both show.
*/
export {};components/forms/checkbox/checkbox.tsx
"use client";
import * as Primitive from "@radix-ui/react-checkbox";
import type { ComponentPropsWithRef } from "react";
import { Check, Minus } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./checkbox.module.css";
export type CheckboxProps = ComponentPropsWithRef<typeof Primitive.Root>;
/** `checked` may be true, false, or "indeterminate": a state that comes from
* the data (some children selected), not a variant the author picks. */
export function Checkbox({ className, ...props }: CheckboxProps) {
return (
<Primitive.Root {...props} className={cn(styles.root, className)}>
<Primitive.Indicator className={styles.indicator}>
<Check
className={cn(styles.glyph, styles.check)}
strokeWidth={3}
aria-hidden="true"
/>
<Minus
className={cn(styles.glyph, styles.mixed)}
strokeWidth={3}
aria-hidden="true"
/>
</Primitive.Indicator>
</Primitive.Root>
);
}components/forms/checkbox/checkbox.module.css
@layer primitive {
.root {
display: inline-grid;
width: 16px;
height: 16px;
flex: none;
place-items: center;
padding: 0;
border: 1px solid var(--line-heavy);
border-radius: var(--radius-1);
background: var(--surface-panel);
color: var(--fill-ink);
cursor: pointer;
transition:
background-color var(--dur-2) var(--ease),
border-color var(--dur-2) var(--ease);
}
.root:where(:hover:not(:disabled)) {
border-color: var(--ink-3);
}
.root[data-state="checked"],
.root[data-state="indeterminate"] {
border-color: var(--fill);
background: var(--fill);
}
.root[aria-invalid="true"] {
border-color: var(--crit);
}
.root:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.root:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.indicator {
display: grid;
place-items: center;
}
.glyph {
width: 12px;
height: 12px;
}
/* The state decides which glyph shows, so both can never appear at once. */
.root[data-state="checked"] .mixed,
.root[data-state="indeterminate"] .check {
display: none;
}
}RadioGroup
one choice; arrows move, Tab leaves
A group is one tab stop. Arrow keys move the selection; name the group with a Fieldset legend.
Sourcecomponents/forms/radio-group/doc.ts · components/forms/radio-group/radio-group.tsx · components/forms/radio-group/radio-group.module.css
components/forms/radio-group/doc.ts
/**
* RadioGroup and Radio — one choice from a short list.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* RadioGroup value?, defaultValue?, onValueChange?, name?, required?,
* disabled?, orientation?
* Radio value, REQUIRED; disabled?
*
* # Behaviour
*
* R1 Exactly one option can be selected. Arrow keys move the selection
* between options; Tab enters and leaves the group as one stop.
* R2 Each Radio is a 16px circle with an 8px `--fill` dot when selected.
* R3 Options stack with a `--space-4` gap.
* R4 The group is named by a Fieldset legend, and each option by an inline
* Label around it.
* R5 Inside a form the selected value submits under `name`.
*
* # RadioGroup or Select?
*
* Up to about five options that should all be visible: a radio group. More,
* or where space is short: a select.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The primitive supplies roving focus (R1) and the hidden input for R5.
*/
export {};components/forms/radio-group/radio-group.tsx
"use client";
import * as Primitive from "@radix-ui/react-radio-group";
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./radio-group.module.css";
export type RadioGroupProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type RadioProps = ComponentPropsWithRef<typeof Primitive.Item>;
/** Owns the value and arrow-key navigation. It does not own the question:
* that is a Fieldset legend, announced before every option. */
export function RadioGroup({ className, ...props }: RadioGroupProps) {
return <Primitive.Root {...props} className={cn(styles.group, className)} />;
}
export function Radio({ className, ...props }: RadioProps) {
return (
<Primitive.Item {...props} className={cn(styles.radio, className)}>
<Primitive.Indicator className={styles.indicator} />
</Primitive.Item>
);
}components/forms/radio-group/radio-group.module.css
@layer primitive {
.group {
display: grid;
gap: var(--space-4);
}
.radio {
display: inline-grid;
width: 16px;
height: 16px;
flex: none;
place-items: center;
padding: 0;
border: 1px solid var(--line-heavy);
border-radius: var(--radius-pill);
background: var(--surface-panel);
cursor: pointer;
transition: border-color var(--dur-2) var(--ease);
}
.radio:where(:hover:not(:disabled)) {
border-color: var(--ink-3);
}
.radio[data-state="checked"] {
border-color: var(--fill);
}
.radio:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.radio:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--fill);
}
}Switch
takes effect immediately
Switch or checkbox? A switch changes a setting now. Anything that waits for a Save button is a checkbox.
Sourcecomponents/forms/switch/doc.ts · components/forms/switch/switch.tsx · components/forms/switch/switch.module.css
components/forms/switch/doc.ts
/**
* Switch — a setting that takes effect immediately.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* checked?, defaultChecked?, onCheckedChange?, disabled?, name?, value?
*
* # Behaviour
*
* R1 A 32×18 track with a 12px thumb: `--surface-sunk` and a muted thumb when
* off, `--fill` with a `--fill-ink` thumb on the right when on.
* R2 Announced as a switch, "on" or "off" — not "checked".
* R3 Space and Enter toggle it.
* R4 It changes a setting now. Anything that waits for a Save button is a
* Checkbox.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The thumb moves with a transform so the transition is cheap; the motion
* tokens shorten it under reduced motion.
*/
export {};components/forms/switch/switch.tsx
"use client";
import * as Primitive from "@radix-ui/react-switch";
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./switch.module.css";
export type SwitchProps = ComponentPropsWithRef<typeof Primitive.Root>;
/** An immediate on/off setting. Announced as a switch, so it reads "on/off"
* rather than "checked"; see doc.ts for when a checkbox is right instead. */
export function Switch({ className, ...props }: SwitchProps) {
return (
<Primitive.Root {...props} className={cn(styles.root, className)}>
<Primitive.Thumb className={styles.thumb} />
</Primitive.Root>
);
}components/forms/switch/switch.module.css
@layer primitive {
.root {
position: relative;
display: inline-flex;
width: 32px;
height: 18px;
flex: none;
align-items: center;
padding: 0;
border: 1px solid var(--line-heavy);
border-radius: var(--radius-pill);
background: var(--surface-sunk);
cursor: pointer;
transition:
background-color var(--dur-2) var(--ease),
border-color var(--dur-2) var(--ease);
}
.root[data-state="checked"] {
border-color: var(--fill);
background: var(--fill);
}
.root:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.root:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.thumb {
display: block;
width: 12px;
height: 12px;
border-radius: var(--radius-pill);
background: var(--ink-3);
transform: translateX(2px);
transition:
transform var(--dur-2) var(--ease),
background-color var(--dur-2) var(--ease);
}
.root[data-state="checked"] .thumb {
background: var(--fill-ink);
transform: translateX(16px);
}
}Select
size · placeholder · invalid · disabled
A select holds a value and shows it. A menu runs an action and forgets it. They look alike and are announced differently, so a list of actions belongs in a menu.
Sourcecomponents/forms/select/doc.ts · components/forms/select/select.tsx · components/forms/select/select.variants.ts · components/forms/select/select.module.css
components/forms/select/doc.ts
/**
* Select — one value from a list, bound to a form.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Select value?, defaultValue?, onValueChange?, name?,
* required?, disabled?
* SelectTrigger size? ("sm" | "md" | "lg"), placeholder?, and the
* attributes a Field hands down (id, aria-*)
* SelectContent the list
* SelectItem value, REQUIRED; disabled?; children are its label
*
* # Behaviour
*
* R1 The trigger is exactly the size's control token tall and padded like an
* Input (forms R1), so a select and an input line up.
* R2 The trigger always reads back the chosen item's label, or the
* placeholder in `--ink-3` when nothing is chosen.
* R3 The list floats on the shared elevated surface, at least as wide as the
* trigger, and is never clipped by a scrolling or overflow-hidden parent.
* R4 The highlighted item follows the pointer AND the keyboard. The selected
* item shows a tick.
* R5 Keyboard: arrows move, typing jumps to a matching item, Enter or Space
* selects, Escape closes without changing the value.
* R6 `aria-invalid="true"` on the trigger turns its border and focus ring
* `--crit`.
*
* # Select or menu?
*
* A select holds a value and shows it. A menu runs an action and forgets it.
* They look alike and are announced differently; a menu of actions built from
* a Select tells a screen reader there is a value that does not exist.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The content is portalled (R3) and positioned as a popper below the
* trigger. `max-height` follows the space the primitive reports as available,
* capped at 320px. The stylesheet differs between frameworks only in those
* variable names: `--radix-select-*` in React, `--bits-select-*` in Svelte.
*
* R2 in Svelte: bits-ui mounts the items only while the list is open, so a
* closed trigger can find the chosen label only in the `items` passed to
* `Select` (`{ value, label }[]`). Without them it reads back the raw value.
* Radix collects item labels while closed, so React needs no `items`.
*
* The trigger is announced as a combobox (the select-only combobox pattern) in
* both frameworks. Radix sets the role; bits-ui does not, so the Svelte
* trigger sets it.
*/
export {};components/forms/select/select.tsx
"use client";
import * as Primitive from "@radix-ui/react-select";
import type { ComponentPropsWithRef } from "react";
import { Check, ChevronDown } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import styles from "./select.module.css";
import {
selectTriggerVariants,
type SelectTriggerVariants,
} from "./select.variants";
/* A value bound to a form, not a menu of actions. The two look alike and are
announced differently; see doc.ts. */
export type SelectProps = ComponentPropsWithRef<typeof Primitive.Root>;
export type SelectTriggerProps = Omit<
ComponentPropsWithRef<typeof Primitive.Trigger>,
"children"
> &
SelectTriggerVariants & {
/** Shown until a value is chosen. */
placeholder?: string;
};
export type SelectContentProps = ComponentPropsWithRef<
typeof Primitive.Content
>;
export type SelectItemProps = ComponentPropsWithRef<typeof Primitive.Item>;
export function Select(props: SelectProps) {
return <Primitive.Root {...props} />;
}
/** The closed control. It always reads back the chosen value, which is what
* distinguishes a select from a menu. */
export function SelectTrigger({
size,
placeholder,
className,
...props
}: SelectTriggerProps) {
return (
<Primitive.Trigger
{...props}
className={cn(selectTriggerVariants({ size }), className)}
>
<span className={styles.value}>
<Primitive.Value placeholder={placeholder} />
</span>
<Primitive.Icon asChild>
<ChevronDown className={styles.chevron} aria-hidden="true" />
</Primitive.Icon>
</Primitive.Trigger>
);
}
export function SelectContent({
className,
position = "popper",
sideOffset = 6,
children,
...props
}: SelectContentProps) {
return (
// Portalled, so an overflow-hidden ancestor cannot clip the list.
<Primitive.Portal>
<Primitive.Content
{...props}
position={position}
sideOffset={sideOffset}
className={cn(surface.elevated, styles.content, className)}
>
<Primitive.Viewport className={styles.viewport}>
{children}
</Primitive.Viewport>
</Primitive.Content>
</Primitive.Portal>
);
}
export function SelectItem({ className, children, ...props }: SelectItemProps) {
return (
<Primitive.Item {...props} className={cn(styles.item, className)}>
<Primitive.ItemText>{children}</Primitive.ItemText>
<Primitive.ItemIndicator>
<Check className={styles.tick} strokeWidth={2.4} aria-hidden="true" />
</Primitive.ItemIndicator>
</Primitive.Item>
);
}components/forms/select/select.variants.ts
import { cva, type VariantProps } from "class-variance-authority";
import styles from "./select.module.css";
export const selectTriggerVariants = cva(styles.trigger, {
variants: {
size: { sm: styles.sm, md: styles.md, lg: styles.lg },
},
defaultVariants: { size: "md" },
});
export type SelectTriggerVariants = VariantProps<typeof selectTriggerVariants>;components/forms/select/select.module.css
@layer primitive {
.trigger {
display: inline-flex;
box-sizing: border-box;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block: 0;
border: 1px solid var(--line-strong);
border-radius: var(--radius-2);
background: var(--surface-panel);
color: var(--ink);
font: inherit;
font-size: var(--text-control, var(--text-13));
line-height: normal;
text-align: start;
cursor: pointer;
transition: border-color var(--dur-2) var(--ease);
}
/* Same heights and padding as Input, so a select and an input of one size
line up (forms R1). */
.sm {
height: var(--control-sm);
padding-inline: var(--space-4);
}
.md {
height: var(--control-md);
padding-inline: var(--space-5);
}
.lg {
height: var(--control-lg);
padding-inline: var(--space-6);
}
.trigger:where(:hover:not(:disabled, :focus-visible)) {
border-color: var(--line-heavy);
}
.trigger[data-placeholder] {
color: var(--ink-3);
}
.trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.trigger[aria-invalid="true"] {
border-color: var(--crit);
}
.trigger[aria-invalid="true"]:focus-visible {
outline-color: var(--crit);
}
.trigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
width: 14px;
height: 14px;
flex: none;
color: var(--ink-3);
}
.content {
z-index: var(--z-palette);
min-width: var(--radix-select-trigger-width);
max-height: min(320px, var(--radix-select-content-available-height));
}
.viewport {
padding: var(--space-2);
}
.item {
display: flex;
min-height: var(--control-md);
align-items: center;
justify-content: space-between;
gap: var(--space-5);
padding-inline: var(--space-5);
border-radius: var(--radius-1);
color: var(--ink-2);
font-size: var(--text-control, var(--text-13));
cursor: pointer;
outline: none;
user-select: none;
}
/* The primitive sets data-highlighted for pointer AND keyboard; a :hover
rule alone would leave keyboard users with no position. */
.item[data-highlighted] {
background: var(--surface-hover-2);
color: var(--ink);
}
.item[data-state="checked"] {
color: var(--ink);
font-weight: var(--weight-medium);
}
.item[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.tick {
width: 14px;
height: 14px;
flex: none;
color: var(--accent);
}
}SegmentedControl
a radio group as segments · icons · sizes · full width
View: board
For choices that apply at once. It is a radio group: one tab stop, and the arrows move and select. A choice that waits for Save belongs in a RadioGroup or a Select.
Sourcecomponents/forms/segmented-control/doc.ts · components/forms/segmented-control/segmented-control.tsx · components/forms/segmented-control/segmented-control.module.css
components/forms/segmented-control/doc.ts
/**
* SegmentedControl — one of a few options, all visible, taking effect now.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED, names the choice
* segments { value, label, icon?, iconOnly?, disabled? }[]
* value?, defaultValue?, onValueChange?, size?, full?, disabled?
*
* # Behaviour
*
* R1 A radio group: one tab stop, arrow keys move AND select. It is for
* choices that apply at once (a view, a range); a choice saved later
* belongs in a RadioGroup or Select.
* R2 An icon-only segment is named by its label.
* R3 It is exactly its control size tall, like every sized control.
*/
export {};components/forms/segmented-control/segmented-control.tsx
"use client";
import * as Primitive from "@radix-ui/react-radio-group";
import type { LucideIcon } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./segmented-control.module.css";
export type Segment = {
value: string;
label: string;
icon?: LucideIcon;
/** Show only the icon; the label becomes its name. */
iconOnly?: boolean;
disabled?: boolean;
};
export type SegmentedControlProps = {
/** Names the choice. */
label: string;
segments: readonly Segment[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: "sm" | "md" | "lg";
/** Stretch to the container, segments sharing the width. */
full?: boolean;
disabled?: boolean;
className?: string;
};
/** One of a few options, all visible, taking effect at once: a view, a
* range, a mode. It is a radio group — one tab stop, arrows move and
* select — styled as segments. */
export function SegmentedControl({
label,
segments,
size = "md",
full = false,
className,
...props
}: SegmentedControlProps) {
return (
<Primitive.Root
{...props}
aria-label={label}
orientation="horizontal"
data-size={size}
data-full={full || undefined}
className={cn(styles.root, className)}
>
{segments.map((segment) => {
const Icon = segment.icon;
return (
<Primitive.Item
key={segment.value}
value={segment.value}
disabled={segment.disabled}
aria-label={segment.iconOnly ? segment.label : undefined}
title={segment.iconOnly ? segment.label : undefined}
className={styles.item}
>
{Icon ? <Icon aria-hidden="true" /> : null}
{segment.iconOnly ? null : segment.label}
</Primitive.Item>
);
})}
</Primitive.Root>
);
}components/forms/segmented-control/segmented-control.module.css
@layer primitive {
.root {
display: inline-flex;
height: var(--control-md);
box-sizing: border-box;
align-items: stretch;
gap: 2px;
padding: 2px;
border: 1px solid var(--line);
border-radius: var(--radius-2);
background: var(--surface-sunk);
}
.root[data-size="sm"] {
height: var(--control-sm);
}
.root[data-size="lg"] {
height: var(--control-lg);
}
.root[data-full] {
display: flex;
width: 100%;
}
.item {
display: inline-flex;
flex: 1 0 auto;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: 0 var(--space-5);
border: 0;
border-radius: calc(var(--radius-2) - 2px);
background: transparent;
color: var(--ink-2);
cursor: pointer;
font: inherit;
font-size: var(--text-control, var(--text-13));
white-space: nowrap;
transition:
background-color var(--dur-2) var(--ease),
color var(--dur-2) var(--ease);
}
.item svg {
width: 15px;
height: 15px;
}
.item:hover:not([data-disabled]) {
color: var(--ink);
}
.item[data-state="checked"] {
background: var(--surface-panel);
box-shadow: var(--shadow);
color: var(--ink);
font-weight: var(--weight-medium);
}
.item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
.item[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
}Slider
single · range · formatted · disabled
Sourcecomponents/forms/slider/doc.ts · components/forms/slider/slider.tsx · components/forms/slider/hold.ts · components/forms/slider/slider.module.css
components/forms/slider/doc.ts
/**
* Slider — a value, or a range, on a continuous scale.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label REQUIRED; shown above unless hideLabel
* value?, defaultValue? a number, or [low, high] for a range
* onValueChange?, onValueCommit? (on release: save here)
* min?, max?, step?, formatValue?, disabled?
*
* # Behaviour
*
* R1 Each thumb is named (a range's "Minimum …" and "Maximum …") and
* announces its value as formatted.
* R2 Arrows step, Page keys step by ten, Home and End jump to the ends.
* R3 A range's thumbs cannot cross.
* R4 For a value whose exact number matters, pair it with — or use — a
* NumberInput.
*/
export {};components/forms/slider/slider.tsx
"use client";
import * as Primitive from "@radix-ui/react-slider";
import { useId, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils/cn";
import { holdThumb, pageBy } from "./hold";
import styles from "./slider.module.css";
type Value = number | readonly [number, number];
export type SliderProps<V extends Value> = {
/** Names the slider; shown above it unless hideLabel. */
label: string;
hideLabel?: boolean;
/** One number, or a pair for a range. */
value?: V;
defaultValue?: V;
onValueChange?: (value: V) => void;
/** Fires when the pointer lets go or a key is released: save here. */
onValueCommit?: (value: V) => void;
min?: number;
max?: number;
step?: number;
/** How a value reads, printed and announced: "40%", "$250". */
formatValue?: (value: number) => string;
disabled?: boolean;
className?: string;
};
const asArray = (v: Value | undefined) =>
v === undefined ? undefined : typeof v === "number" ? [v] : [...v];
/** A value — or a range — on a continuous scale, where the exact number
* matters less than where it sits. Each thumb announces its value as
* formatted; arrows step, Page keys step by ten, Home and End jump. */
export function Slider<V extends Value>({
label,
hideLabel = false,
value,
defaultValue,
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
formatValue = String,
disabled,
className,
}: SliderProps<V>) {
const id = useId();
const range = Array.isArray(value ?? defaultValue);
const [local, setLocal] = useState<number[]>(
asArray(value ?? defaultValue) ?? [min],
);
const current = asArray(value) ?? local;
const out = (values: number[]) =>
(range ? [values[0], values[1]] : values[0]) as unknown as V;
const names = range ? [`Minimum ${label}`, `Maximum ${label}`] : [label];
const set = (values: number[]) => {
setLocal(values);
onValueChange?.(out(values));
};
/* The primitive lets a Page key jump one thumb over the other (it sorts
and swaps). Page keys are handled here, held a step short instead. */
const page = (event: KeyboardEvent<HTMLSpanElement>, index: number) => {
const by = pageBy(event.key, step);
if (by === null) return;
event.preventDefault();
const next = holdThumb(current, index, current[index] + by, {
min,
max,
step,
});
set(next);
onValueCommit?.(out(next));
};
return (
<div className={cn(styles.root, className)}>
{hideLabel ? null : (
<div className={styles.head}>
<span id={`${id}-label`} className={styles.label}>
{label}
</span>
<output className={styles.output} aria-hidden="true">
{current.map(formatValue).join(" – ")}
</output>
</div>
)}
<Primitive.Root
className={styles.slider}
value={current}
onValueChange={set}
onValueCommit={(values) => onValueCommit?.(out(values))}
min={min}
max={max}
step={step}
minStepsBetweenThumbs={range ? 1 : undefined}
disabled={disabled}
aria-labelledby={hideLabel ? undefined : `${id}-label`}
>
<Primitive.Track className={styles.track}>
<Primitive.Range className={styles.range} />
</Primitive.Track>
{current.map((v, index) => (
<Primitive.Thumb
key={index}
className={styles.thumb}
aria-label={names[index]}
aria-valuetext={formatValue(v)}
onKeyDown={(event) => page(event, index)}
/>
))}
</Primitive.Root>
</div>
);
}components/forms/slider/hold.ts
/* A slider thumb's next value. Pure, and shared by both apps. */
/** `values` with thumb `index` moved to `next`: inside the bounds, and — for
* a range — a step short of its neighbour, so thumbs never cross or swap. */
export function holdThumb(
values: readonly number[],
index: number,
next: number,
{ min, max, step }: { min: number; max: number; step: number },
) {
const low = index > 0 ? values[index - 1] + step : min;
const high = index < values.length - 1 ? values[index + 1] - step : max;
const held = [...values];
held[index] = Math.min(high, Math.max(low, next));
return held;
}
/** How far a Page key moves a thumb: ten steps, or null for other keys. */
export const pageBy = (key: string, step: number) =>
key === "PageUp" ? 10 * step : key === "PageDown" ? -10 * step : null;components/forms/slider/slider.module.css
@layer primitive {
.root {
display: grid;
gap: var(--space-3);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
font-size: var(--text-13);
}
.label {
color: var(--ink);
font-weight: var(--weight-medium);
}
.output {
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.slider {
position: relative;
display: flex;
height: var(--control-sm);
align-items: center;
touch-action: none;
user-select: none;
}
.track {
position: relative;
height: 4px;
flex: 1;
overflow: hidden;
border-radius: var(--radius-pill);
background: var(--line-strong);
}
.range {
position: absolute;
height: 100%;
background: var(--accent);
}
.thumb {
display: block;
width: 18px;
height: 18px;
border: 2px solid var(--accent);
border-radius: 50%;
background: var(--surface-panel);
box-shadow: var(--shadow);
cursor: grab;
transition: transform var(--dur-1) var(--ease);
}
.thumb:hover {
transform: scale(1.08);
}
.thumb:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.slider[data-disabled] {
opacity: 0.45;
}
.slider[data-disabled] .thumb {
cursor: not-allowed;
}
}NumberInput
a spin button · bounds · steps · units
1 to 500.
Size sm.
Type freely; it settles when you leave. Arrows step (Shift for ten), Page keys step by ten, Home and End jump to the bounds. On Enter or blur the value is clamped and rounded to the step; unreadable text reverts.
Sourcecomponents/forms/number-input/doc.ts · components/forms/number-input/number-input.tsx · components/forms/number-input/number.ts · components/forms/number-input/number-input.module.css
components/forms/number-input/doc.ts
/**
* NumberInput — a number to type or step.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* value?, defaultValue? number | null (empty)
* onValueChange?, min?, max?, step?, unit?, size?, disabled?
* and an input's attributes, so a Field can wire it
*
* # Behaviour
*
* R1 A spin button: announced with its value (and unit) and its bounds.
* R2 Arrow keys step (Shift for ten); Page keys step by ten; Home and End
* jump to the bounds when there are bounds.
* R3 Typing is free; the value settles on Enter or leaving the field —
* clamped to the bounds and rounded to the step. Unreadable text reverts.
* R4 The steppers are pointer conveniences, out of the tab order, and are
* disabled at the bounds.
* R5 It is exactly its control size tall.
*/
export {};components/forms/number-input/number-input.tsx
"use client";
import {
useState,
type ComponentPropsWithRef,
type KeyboardEvent,
} from "react";
import { Minus, Plus } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import styles from "./number-input.module.css";
import { clampStep, keyStep, parseNumber } from "./number";
export type NumberInputProps = Omit<
ComponentPropsWithRef<"input">,
| "value"
| "defaultValue"
| "onChange"
| "size"
| "min"
| "max"
| "step"
| "type"
> & {
/** Null is empty. */
value?: number | null;
defaultValue?: number | null;
onValueChange?: (value: number | null) => void;
min?: number;
max?: number;
step?: number;
/** Appended when the value is announced: "seats", "GB". */
unit?: string;
size?: "sm" | "md" | "lg";
};
/** A number to type or step: seats, a limit, a quantity. It is a spin
* button — arrows step (Shift for ten), Page keys step by ten, Home and End
* jump to the bounds — and it settles to a valid value when you leave it. */
export function NumberInput({
value,
defaultValue = null,
onValueChange,
min,
max,
step = 1,
unit,
size = "md",
disabled,
className,
onBlur,
onKeyDown,
"aria-invalid": invalid,
...props
}: NumberInputProps) {
const [local, setLocal] = useState<number | null>(defaultValue);
const current = value !== undefined ? value : local;
const [draft, setDraft] = useState<string | null>(null);
const bounds = { min, max, step };
const commit = (next: number | null) => {
const settled = next === null ? null : clampStep(next, bounds);
setDraft(null);
if (value === undefined) setLocal(settled);
if (settled !== current) onValueChange?.(settled);
};
const stepBy = (by: number) => commit((current ?? min ?? 0) + by);
const settleDraft = () => {
if (draft === null) return;
const parsed = parseNumber(draft);
if (parsed === "invalid") setDraft(null);
else commit(parsed);
};
return (
<div
data-size={size}
data-invalid={invalid === true || invalid === "true" ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn(styles.root, className)}
>
<button
type="button"
tabIndex={-1}
className={styles.step}
aria-label="Decrease"
disabled={
disabled || (min !== undefined && current !== null && current <= min)
}
onClick={() => stepBy(-step)}
>
<Minus aria-hidden="true" />
</button>
<input
{...props}
type="text"
inputMode="decimal"
role="spinbutton"
autoComplete="off"
disabled={disabled}
aria-invalid={invalid}
aria-valuenow={current ?? undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={
current === null ? "Empty" : `${current}${unit ? ` ${unit}` : ""}`
}
className={styles.input}
value={draft ?? (current === null ? "" : String(current))}
onChange={(event) => setDraft(event.target.value)}
onBlur={(event) => {
settleDraft();
onBlur?.(event);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.key === "Enter") {
settleDraft();
return;
}
const change = keyStep(event.key, event.shiftKey, bounds);
if (!change) return;
event.preventDefault();
if ("to" in change) commit(change.to);
else stepBy(change.by);
}}
/>
<button
type="button"
tabIndex={-1}
className={styles.step}
aria-label="Increase"
disabled={
disabled || (max !== undefined && current !== null && current >= max)
}
onClick={() => stepBy(step)}
>
<Plus aria-hidden="true" />
</button>
</div>
);
}components/forms/number-input/number.ts
/* NumberInput's maths, shared by both apps. */
/** Decimals in a step, so 0.1 + 0.2 lands on 0.3, not 0.30000000000000004. */
const decimals = (step: number) => {
const text = String(step);
return text.includes(".") ? text.length - text.indexOf(".") - 1 : 0;
};
export function clampStep(
value: number,
{ min, max, step }: { min?: number; max?: number; step: number },
) {
let v = value;
if (min !== undefined) v = Math.max(min, v);
if (max !== undefined) v = Math.min(max, v);
return Number(v.toFixed(decimals(step)));
}
/** A typed string as a number: blank is null, and so is anything unreadable
* (the field then reverts to its last good value). Commas are ignored. */
export function parseNumber(text: string): number | null | "invalid" {
const trimmed = text.replace(/,/g, "").trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : "invalid";
}
/** The change a key makes, or null for keys that are not steps. */
export function keyStep(
key: string,
shift: boolean,
{ min, max, step }: { min?: number; max?: number; step: number },
): { by: number } | { to: number } | null {
const big = step * 10;
switch (key) {
case "ArrowUp":
return { by: shift ? big : step };
case "ArrowDown":
return { by: -(shift ? big : step) };
case "PageUp":
return { by: big };
case "PageDown":
return { by: -big };
case "Home":
return min !== undefined ? { to: min } : null;
case "End":
return max !== undefined ? { to: max } : null;
default:
return null;
}
}components/forms/number-input/number-input.module.css
@layer primitive {
/* A text field between two steppers, drawn as one control. */
.root {
display: inline-grid;
width: 100%;
box-sizing: border-box;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-2);
background: var(--surface-panel);
}
.root[data-size="sm"] {
height: var(--control-sm);
}
.root[data-size="md"] {
height: var(--control-md);
}
.root[data-size="lg"] {
height: var(--control-lg);
}
.root:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.root[data-invalid] {
border-color: var(--crit);
}
.root[data-disabled] {
opacity: 0.55;
}
.input {
min-width: 0;
padding: 0 var(--space-3);
border: 0;
background: transparent;
color: var(--ink);
font: inherit;
font-size: var(--text-control, var(--text-13));
font-variant-numeric: tabular-nums;
text-align: center;
}
.input:focus {
outline: none;
}
.step {
display: grid;
width: calc(var(--control-sm) - 2px);
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--ink-2);
cursor: pointer;
}
.step:first-child {
border-right: 1px solid var(--line);
}
.step:last-child {
border-left: 1px solid var(--line);
}
.step:hover:not(:disabled) {
background: var(--surface-hover-2);
color: var(--ink);
}
.step:disabled {
color: var(--ink-4, var(--ink-3));
cursor: not-allowed;
opacity: 0.5;
}
.step svg {
width: 14px;
height: 14px;
}
}