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 snippet with a parameter 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.
Source src/lib/components/forms/field/doc.ts · src/lib/components/forms/field/Field.svelte · src/lib/components/forms/label/doc.ts · src/lib/components/forms/label/Label.svelte
src/lib/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 {};src/lib/components/forms/field/Field.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn';
import Label from '../label/Label.svelte';
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: string;
hint?: string;
error?: string;
required?: boolean;
class?: string;
/** A snippet with a parameter: the props the control must carry; see doc.ts. */
children: Snippet<[FieldControlProps]>;
};
let { label, hint, error, required, class: className = '', children }: FieldProps = $props();
// Unique per instance and stable across a server render and hydration.
const id = $props.id();
const hintId = $derived(hint ? `${id}-hint` : undefined);
const errorId = $derived(error ? `${id}-error` : undefined);
// Error first: it is announced first and it is the more urgent.
const describedBy = $derived([errorId, hintId].filter(Boolean).join(' ') || undefined);
const control = $derived<FieldControlProps>({
id,
'aria-describedby': describedBy,
// Absent, never false: aria-invalid="false" is its own announcement.
'aria-invalid': error ? true : undefined,
required: required || undefined
});
</script>
<div class={cn(styles.root, className)}>
<Label for={id}>
{label}
<!-- Visual only: `required` on the control is the announcement. -->
{#if required}<span class={styles.required} aria-hidden="true">*</span>{/if}
</Label>
{@render children(control)}
{#if error}<p id={errorId} class={styles.error}>{error}</p>{/if}
{#if hint}<p id={hintId} class={styles.hint}>{hint}</p>{/if}
</div>src/lib/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 {};src/lib/components/forms/label/Label.svelte
<script lang="ts">
import { Label as Primitive, type LabelRootProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import { labelVariants, type LabelVariants } from './label.variants';
export type LabelProps = Omit<LabelRootProps, 'class'> & LabelVariants & { class?: string };
/* 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. */
let { variant, class: className = '', children, ...rest }: LabelProps = $props();
</script>
<Primitive.Root {...rest} class={cn(labelVariants({ variant }), className)}>
{@render children?.()}
</Primitive.Root>Fieldset
legend · hint · error — one question, several controls
Source src/lib/components/forms/fieldset/doc.ts · src/lib/components/forms/fieldset/Fieldset.svelte · src/lib/components/forms/fieldset/fieldset.module.css
src/lib/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 {};src/lib/components/forms/fieldset/Fieldset.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLFieldsetAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './fieldset.module.css';
export type FieldsetProps = Omit<HTMLFieldsetAttributes, 'class' | 'title'> & {
class?: string;
/** Names the group; announced before every control inside it. */
legend: string;
hint?: string;
error?: string;
children: Snippet;
};
let { legend, hint, error, class: className = '', children, ...rest }: FieldsetProps = $props();
const id = $props.id();
const hintId = $derived(hint ? `${id}-hint` : undefined);
const errorId = $derived(error ? `${id}-error` : undefined);
const describedBy = $derived([errorId, hintId].filter(Boolean).join(' ') || undefined);
</script>
<!-- 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. -->
<fieldset
{...rest}
aria-describedby={describedBy}
data-invalid={error ? '' : undefined}
class={cn(styles.root, className)}
>
<legend class={styles.legend}>{legend}</legend>
<!-- A hint qualifies the question and belongs with it. -->
{#if hint}<p id={hintId} class={styles.hint}>{hint}</p>{/if}
<div class={styles.body}>{@render children()}</div>
<!-- An error is about the answer and belongs after it. -->
{#if error}<p id={errorId} class={styles.error}>{error}</p>{/if}
</fieldset>src/lib/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
Source src/lib/components/forms/input/doc.ts · src/lib/components/forms/input/Input.svelte · src/lib/components/forms/input/Textarea.svelte · src/lib/components/forms/input/input.variants.ts · src/lib/components/forms/input/input.module.css
src/lib/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 {};src/lib/components/forms/input/Input.svelte
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { inputVariants, type InputVariants } from './input.variants';
export type InputProps = Omit<HTMLInputAttributes, 'class' | 'size'> &
InputVariants & {
class?: string;
};
let { class: className = '', size, mono, value = $bindable(), ...rest }: InputProps = $props();
</script>
<input {...rest} bind:value class={cn(inputVariants({ size, mono }), className)} />src/lib/components/forms/input/Textarea.svelte
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { inputVariants, type InputVariants } from './input.variants';
export type TextareaProps = Omit<HTMLTextareaAttributes, 'class' | 'size'> &
InputVariants & {
class?: string;
};
/* The same control with rows. It shares Input's variant map, so the two
cannot drift apart in border, focus, hover, or invalid styling. */
let {
class: className = '',
size,
mono,
rows = 4,
value = $bindable(),
...rest
}: TextareaProps = $props();
</script>
<textarea
{...rest}
{rows}
bind:value
class={cn(inputVariants({ size, mono, multiline: true }), className)}></textarea>src/lib/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'>;src/lib/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.
Source src/lib/components/forms/checkbox/doc.ts · src/lib/components/forms/checkbox/Checkbox.svelte · src/lib/components/forms/checkbox/checkbox.module.css
src/lib/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 {};src/lib/components/forms/checkbox/Checkbox.svelte
<script lang="ts">
import { Checkbox as Primitive, type CheckboxRootProps } from 'bits-ui';
import { Check, Minus } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import styles from './checkbox.module.css';
export type CheckboxProps = Omit<CheckboxRootProps, 'class'> & { class?: string };
/* `indeterminate` is a state from the data (some children selected), not a
variant the author picks; activating it checks it. */
let {
class: className = '',
checked = $bindable(false),
indeterminate = $bindable(false),
...rest
}: CheckboxProps = $props();
</script>
<Primitive.Root {...rest} bind:checked bind:indeterminate class={cn(styles.root, className)}>
{#snippet children({ checked: isChecked, indeterminate: isMixed })}
<span class={styles.indicator}>
<!-- The state decides which glyph shows, so both can never appear. -->
{#if isMixed}
<Minus class={styles.glyph} strokeWidth={3} aria-hidden="true" />
{:else if isChecked}
<Check class={styles.glyph} strokeWidth={3} aria-hidden="true" />
{/if}
</span>
{/snippet}
</Primitive.Root>src/lib/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.
Source src/lib/components/forms/radio-group/doc.ts · src/lib/components/forms/radio-group/RadioGroup.svelte · src/lib/components/forms/radio-group/Radio.svelte · src/lib/components/forms/radio-group/radio-group.module.css
src/lib/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 {};src/lib/components/forms/radio-group/RadioGroup.svelte
<script lang="ts">
import { RadioGroup as Primitive, type RadioGroupRootProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import styles from './radio-group.module.css';
export type RadioGroupProps = Omit<RadioGroupRootProps, 'class'> & { class?: string };
/* Owns the value and arrow-key navigation. It does not own the question:
that is a Fieldset legend, announced before every option. */
let { class: className = '', value = $bindable(), ...rest }: RadioGroupProps = $props();
</script>
<Primitive.Root {...rest} bind:value class={cn(styles.group, className)} />src/lib/components/forms/radio-group/Radio.svelte
<script lang="ts">
import { RadioGroup as Primitive, type RadioGroupItemProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import styles from './radio-group.module.css';
export type RadioProps = Omit<RadioGroupItemProps, 'class'> & { class?: string };
let { class: className = '', ...rest }: RadioProps = $props();
</script>
<Primitive.Item {...rest} class={cn(styles.radio, className)}>
{#snippet children({ checked })}
{#if checked}<span class={styles.indicator}></span>{/if}
{/snippet}
</Primitive.Item>src/lib/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.
Source src/lib/components/forms/switch/doc.ts · src/lib/components/forms/switch/Switch.svelte · src/lib/components/forms/switch/switch.module.css
src/lib/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 {};src/lib/components/forms/switch/Switch.svelte
<script lang="ts">
import { Switch as Primitive, type SwitchRootProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import styles from './switch.module.css';
export type SwitchProps = Omit<SwitchRootProps, 'class'> & { class?: string };
/* 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. */
let { class: className = '', checked = $bindable(false), ...rest }: SwitchProps = $props();
</script>
<Primitive.Root {...rest} bind:checked class={cn(styles.root, className)}>
<Primitive.Thumb class={styles.thumb} />
</Primitive.Root>src/lib/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.
Source src/lib/components/forms/select/doc.ts · src/lib/components/forms/select/select.ts · src/lib/components/forms/select/SelectTrigger.svelte · src/lib/components/forms/select/SelectContent.svelte · src/lib/components/forms/select/SelectItem.svelte · src/lib/components/forms/select/select.variants.ts · src/lib/components/forms/select/select.module.css
src/lib/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 {};src/lib/components/forms/select/select.ts
import { Select as Primitive, type SelectRootProps } from 'bits-ui';
/* Re-exported rather than wrapped: Root renders no element and has no style,
and a wrapper would have to declare one bindable `value` and lose the
primitive's single/multiple union. Pass `items` so the closed trigger can
read back the chosen label; see doc.ts § Mechanics. */
export const Select = Primitive.Root;
export type SelectProps = SelectRootProps;src/lib/components/forms/select/SelectTrigger.svelte
<script lang="ts">
import { Select as Primitive, type SelectTriggerProps as TriggerProps } from 'bits-ui';
import { ChevronDown } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import styles from './select.module.css';
import { selectTriggerVariants, type SelectTriggerVariants } from './select.variants';
export type SelectTriggerProps = Omit<TriggerProps, 'class' | 'children' | 'child'> &
SelectTriggerVariants & {
class?: string;
/** Shown until a value is chosen. */
placeholder?: string;
};
/* The closed control. It always reads back the chosen value, which is what
distinguishes a select from a menu. */
let { size, placeholder, class: className = '', ...rest }: SelectTriggerProps = $props();
</script>
<!-- role="combobox": bits-ui leaves the trigger a plain button with a listbox
popup, where Radix marks it a combobox. The select-only combobox pattern is
the one both frameworks should announce; see doc.ts § Mechanics. -->
<Primitive.Trigger {...rest} role="combobox" class={cn(selectTriggerVariants({ size }), className)}>
<Primitive.Value class={styles.value} {placeholder} />
<ChevronDown class={styles.chevron} aria-hidden="true" />
</Primitive.Trigger>src/lib/components/forms/select/SelectContent.svelte
<script lang="ts">
import { Select as Primitive, type SelectContentProps as ContentProps } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import styles from './select.module.css';
export type SelectContentProps = Omit<ContentProps, 'class'> & { class?: string };
let { class: className = '', sideOffset = 6, children, ...rest }: SelectContentProps = $props();
</script>
<!-- Portalled, so an overflow-hidden ancestor cannot clip the list. -->
<Primitive.Portal>
<Primitive.Content {...rest} {sideOffset} class={cn(surface.elevated, styles.content, className)}>
<Primitive.Viewport class={styles.viewport}>
{@render children?.()}
</Primitive.Viewport>
</Primitive.Content>
</Primitive.Portal>src/lib/components/forms/select/SelectItem.svelte
<script lang="ts">
import { Select as Primitive, type SelectItemProps as ItemProps } from 'bits-ui';
import { Check } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import styles from './select.module.css';
export type SelectItemProps = Omit<ItemProps, 'class' | 'children'> & {
class?: string;
children?: import('svelte').Snippet;
};
/* Named `content`, not `children`: the item's own snippet below is also
called children, and a same-named prop would be shadowed by it. */
let { class: className = '', children: content, ...rest }: SelectItemProps = $props();
</script>
<Primitive.Item {...rest} class={cn(styles.item, className)}>
{#snippet children({ selected })}
<span>{@render content?.()}</span>
{#if selected}<Check class={styles.tick} strokeWidth={2.4} aria-hidden="true" />{/if}
{/snippet}
</Primitive.Item>src/lib/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>;src/lib/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(--bits-select-anchor-width);
max-height: min(320px, var(--bits-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.
Source src/lib/components/forms/segmented-control/doc.ts · src/lib/components/forms/segmented-control/SegmentedControl.svelte · src/lib/components/forms/segmented-control/segmented-control.module.css
src/lib/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 {};src/lib/components/forms/segmented-control/SegmentedControl.svelte
<script lang="ts" module>
import type { LayoutGrid } from '$lib/components/utility/icon';
export type Segment = {
value: string;
label: string;
icon?: typeof LayoutGrid;
/** Show only the icon; the label becomes its name. */
iconOnly?: boolean;
disabled?: boolean;
};
</script>
<script lang="ts">
import { RadioGroup as Primitive } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import styles from './segmented-control.module.css';
/* 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. */
let {
label,
segments,
value = $bindable(),
onValueChange,
size = 'md',
full = false,
disabled = false,
class: className = ''
}: {
/** Names the choice. */
label: string;
segments: readonly Segment[];
value?: string;
onValueChange?: (value: string) => void;
size?: 'sm' | 'md' | 'lg';
/** Stretch to the container, segments sharing the width. */
full?: boolean;
disabled?: boolean;
class?: string;
} = $props();
</script>
<Primitive.Root
bind:value
{onValueChange}
{disabled}
aria-label={label}
orientation="horizontal"
data-size={size}
data-full={full || undefined}
class={cn(styles.root, className)}
>
{#each segments as segment (segment.value)}
<Primitive.Item
value={segment.value}
disabled={segment.disabled}
aria-label={segment.iconOnly ? segment.label : undefined}
title={segment.iconOnly ? segment.label : undefined}
class={styles.item}
>
{#if segment.icon}<segment.icon aria-hidden="true" />{/if}
{#if !segment.iconOnly}{segment.label}{/if}
</Primitive.Item>
{/each}
</Primitive.Root>src/lib/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
Source src/lib/components/forms/slider/doc.ts · src/lib/components/forms/slider/Slider.svelte · src/lib/components/forms/slider/hold.ts · src/lib/components/forms/slider/slider.module.css
src/lib/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 {};src/lib/components/forms/slider/Slider.svelte
<script lang="ts">
import { Slider as Primitive } from 'bits-ui';
import { cn } from '$lib/utils/cn';
import { holdThumb, pageBy } from './hold';
import styles from './slider.module.css';
type Value = number | [number, number];
/* 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. */
let {
label,
hideLabel = false,
value = $bindable(0),
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
formatValue = String,
disabled = false,
class: className = ''
}: {
/** Names the slider; shown above it unless hideLabel. */
label: string;
hideLabel?: boolean;
/** One number, or a pair for a range. */
value?: Value;
onValueChange?: (value: Value) => void;
/** Fires when the pointer lets go or a key is released: save here. */
onValueCommit?: (value: Value) => void;
min?: number;
max?: number;
step?: number;
/** How a value reads, printed and announced: "40%", "$250". */
formatValue?: (value: number) => string;
disabled?: boolean;
class?: string;
} = $props();
const id = $props.id();
const range = typeof value !== 'number';
const current = $derived(typeof value === 'number' ? [value] : value);
const names = $derived(range ? [`Minimum ${label}`, `Maximum ${label}`] : [label]);
const out = (values: number[]): Value => (range ? [values[0], values[1]] : values[0]);
const bounds = $derived({ min, max, step });
const set = (next: number[]) => {
if (next.every((v, i) => v === current[i])) return;
value = out(next);
onValueChange?.(value);
};
/* The primitive only swaps thumbs that meet; every change passes through
holdThumb, which stops them a step short. It also has no Page keys. */
const moved = (values: number[]) => {
const index = values[0] !== current[0] ? 0 : values.length - 1;
return holdThumb(current, index, values[index], bounds);
};
const page = (event: KeyboardEvent, index: number) => {
const by = pageBy(event.key, step);
if (by === null) return;
event.preventDefault();
set(holdThumb(current, index, current[index] + by, bounds));
onValueCommit?.(value);
};
const shared = $derived({
class: styles.slider,
min,
max,
step,
disabled,
'aria-labelledby': hideLabel ? undefined : `${id}-label`
});
</script>
{#snippet marks()}
<span class={styles.track}><Primitive.Range class={styles.range} /></span>
{#each current as v, index (index)}
<Primitive.Thumb
{index}
class={styles.thumb}
aria-label={names[index]}
aria-valuetext={formatValue(v)}
onkeydown={(event: KeyboardEvent) => page(event, index)}
/>
{/each}
{/snippet}
<div class={cn(styles.root, className)}>
{#if !hideLabel}
<div class={styles.head}>
<span id="{id}-label" class={styles.label}>{label}</span>
<output class={styles.output} aria-hidden="true"
>{current.map(formatValue).join(' – ')}</output
>
</div>
{/if}
{#if range}
<Primitive.Root
{...shared}
type="multiple"
autoSort={false}
bind:value={() => current, (values) => set(moved(values))}
onValueCommit={() => onValueCommit?.(value)}
>
{@render marks()}
</Primitive.Root>
{:else}
<Primitive.Root
{...shared}
type="single"
value={current[0]}
onValueChange={(v) => {
value = v;
onValueChange?.(v);
}}
onValueCommit={(v) => onValueCommit?.(v)}
>
{@render marks()}
</Primitive.Root>
{/if}
</div>src/lib/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;src/lib/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.
Source src/lib/components/forms/number-input/doc.ts · src/lib/components/forms/number-input/NumberInput.svelte · src/lib/components/forms/number-input/number.ts · src/lib/components/forms/number-input/number-input.module.css
src/lib/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 {};src/lib/components/forms/number-input/NumberInput.svelte
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import { Minus, Plus } from '$lib/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<
HTMLInputAttributes,
'class' | 'value' | 'size' | 'min' | 'max' | 'step' | 'type'
> & {
/** Null is empty. */
value?: 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';
class?: string;
};
/* 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. */
let {
value = $bindable(null),
onValueChange,
min,
max,
step = 1,
unit,
size = 'md',
disabled = false,
class: className = '',
onblur,
onkeydown,
'aria-invalid': invalid,
...rest
}: NumberInputProps = $props();
let draft = $state<string | null>(null);
const bounds = $derived({ min, max, step });
const commit = (next: number | null) => {
const settled = next === null ? null : clampStep(next, bounds);
draft = null;
if (settled === value) return;
value = settled;
onValueChange?.(settled);
};
const stepBy = (by: number) => commit((value ?? min ?? 0) + by);
const settleDraft = () => {
if (draft === null) return;
const parsed = parseNumber(draft);
if (parsed === 'invalid') draft = null;
else commit(parsed);
};
</script>
<div
data-size={size}
data-invalid={invalid === true || invalid === 'true' ? '' : undefined}
data-disabled={disabled ? '' : undefined}
class={cn(styles.root, className)}
>
<button
type="button"
tabindex={-1}
class={styles.step}
aria-label="Decrease"
disabled={disabled || (min !== undefined && value !== null && value <= min)}
onclick={() => stepBy(-step)}
>
<Minus aria-hidden="true" />
</button>
<input
{...rest}
type="text"
inputmode="decimal"
role="spinbutton"
autocomplete="off"
{disabled}
aria-invalid={invalid}
aria-valuenow={value ?? undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={value === null ? 'Empty' : `${value}${unit ? ` ${unit}` : ''}`}
class={styles.input}
value={draft ?? (value === null ? '' : String(value))}
oninput={(event) => (draft = event.currentTarget.value)}
onblur={(event) => {
settleDraft();
onblur?.(event);
}}
onkeydown={(event) => {
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}
class={styles.step}
aria-label="Increase"
disabled={disabled || (max !== undefined && value !== null && value >= max)}
onclick={() => stepBy(step)}
>
<Plus aria-hidden="true" />
</button>
</div>src/lib/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;
}
}src/lib/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;
}
}