Forms (advanced inputs)
The richer, JavaScript-backed form controls from @phauna/ds — the ones the plain native Forms page can't express. Each is a thin token-driven wrapper over a Base UI primitive (checkbox / radio / switch / slider / number-field / combobox / select) styled off data-attributes, or — for the date field — a styled native input. They are CLIENT components (they own focus, keyboard and open/close state), so every controlled demo below lives in one colocated 'use client' island; this page is a server component that just mounts them. Import each from its correct subpath — the interactive controls from @phauna/ds/overlays, FileUpload from @phauna/ds/files. Flip the theme / brand / density in the top bar to confirm every control re-skins from the semantic tokens; tab into any field to see the shared focus ring.
- where they live
- Combobox, Checkbox(Group), Radio(Group), Switch, Slider, NumberInput, SearchInput, Select and DatePicker import from @phauna/ds/overlays; FileUpload from @phauna/ds/files.
- client boundary
- All are client components. Render them from a server page only inside a 'use client' island (here ./AdvancedFormsDemo) — never call useState in this server file.
- controlled vs uncontrolled
- Base-UI controls take value/checked + an onChange callback (controlled) OR defaultValue/defaultChecked (uncontrolled). The demos use whichever reads clearest per control.
- naming
- The brief's "NumberField" / "DateInput" are exported as NumberInput / DatePicker. (The index barrel's DateInput is a date-formatting TYPE, not a control.)
- states shown
- Each control is shown across the states it actually supports — default, filled, disabled, and error/invalid where the wrapper exposes it (NumberInput, SearchInput, DatePicker take invalid).
- accessibility
- Sliders / NumberInputs / Selects / Comboboxes have no built-in visible label, so each demo passes aria-label (or label, or a wrapping <label>/<legend>). Never rely on the control's shape alone.
"use client";
import { useState } from "react";
import { Combobox, type ComboboxOption } from "@savethemarshalldogs/design-system/overlays";
const OPTIONS: ComboboxOption[] = [
{ value: "intake", label: "Intake & triage" },
{ value: "transport", label: "Transport" },
];
const [team, setTeam] = useState<ComboboxOption | null>(null);
<Combobox
items={OPTIONS}
value={team}
onValueChange={setTeam}
placeholder="Search teams…"
aria-label="Team"
/>Combobox (searchable select)
A single-select with a type-to-filter text input — reach for it over Select once the option list gets long enough to want search. items is a {value,label}[]; the selected value is the whole option object (or null), so onValueChange hands you back the picked option. A custom emptyText covers the no-match case.
selected: Transport
- items
- ComboboxOption[] — { value: string; label: string }. Rendered into the filterable listbox.
- value / defaultValue
- The selected OPTION object (ComboboxOption) or null — not just its value string. Use value for controlled, defaultValue for uncontrolled.
- onValueChange
- (value: ComboboxOption | null) => void. Fires with the chosen option (or null when cleared).
- placeholder / emptyText
- placeholder is the empty input hint (default “Search…”); emptyText shows in the popup when nothing matches (default “No matches.”).
- disabled
- Passes through to the Base UI root — the input is non-focusable and dimmed.
- label it
- The wrapper renders only an input (no label). Pass aria-label, or wrap it in a <label>, so the control is named.
Checkbox & CheckboxGroup
Checkbox is a single boolean with an optional label + hint (label makes the whole row the click target). CheckboxGroup coordinates several checkboxes: its value is the array of CHECKED CHILD NAMES, so each child Checkbox needs a name and the group owns the list. row lays them out horizontally.
checked: true
value: [transport, foster]
- Checkbox label / hint
- label renders beside the box and makes the row clickable; hint is muted sub-text under the label. Omit label for a bare box (then it takes className directly).
- Checkbox checked / defaultChecked
- Base UI props: checked + onCheckedChange(next: boolean) for controlled, defaultChecked for uncontrolled. Also disabled, required, name.
- CheckboxGroup value
- string[] of the checked children's name props (value/defaultValue). onValueChange(next: string[]) fires with the new set — so always give each child Checkbox a name.
- CheckboxGroup row
- boolean — lay the children out horizontally (choice-group--row) instead of stacked.
- disabled
- Set on a child to disable just that option, or on the group to disable them all.
Radio & RadioGroup
A single-choice set. Radios are only meaningful inside a RadioGroup — the group holds the selected value and each Radio carries the value it represents (plus an optional label + hint). Use it instead of a Select for a small, always-visible set of mutually-exclusive options. row lays them out horizontally.
value: email
- Radio value
- Required — the value this option contributes to the group. label + hint mirror Checkbox; disabled disables just this option.
- RadioGroup value / defaultValue
- The currently-selected value (controlled) or initial value (uncontrolled). onValueChange(next) fires with the newly-selected value.
- RadioGroup row
- boolean — horizontal layout (e.g. a size picker). Default is stacked.
- disabled
- Set on the group to disable the whole set, or on an individual Radio for one inert option.
- name it
- Pass aria-label (or wrap in a <fieldset>/<legend>) on the RadioGroup — the set needs an accessible name.
Switch
A binary on/off toggle for an immediate setting (vs a checkbox, which states a fact to be submitted). Passing a label renders the whole label+switch row as one click target; without it you get a bare switch (give it an aria-label). On is bg-accent with the thumb shifted right — but the label, never the colour, carries the meaning.
checked: true
- checked / defaultChecked
- Base UI props: checked + onCheckedChange(next: boolean) for controlled, defaultChecked for uncontrolled.
- label
- Optional — renders text beside the switch and wraps both in a clickable <label> (switch-row). Omit it for a bare switch.
- disabled
- Dims the switch and ignores interaction.
- Switch vs Toggle vs Checkbox
- Switch = an instant on/off setting. The native Toggle (src/components/ui) is the older app switch. Checkbox = a value you submit with a form.
- name a bare switch
- Without a label, pass aria-label so screen readers announce what the switch controls.
Slider
A draggable range for a bounded numeric value (a radius, a percentage). min / max / step bound and quantise it; showValue prints the live value beside the track. It has no visible label of its own, so always pass aria-label. Pair the live value text when the exact number matters.
value: 15 mi
- value / defaultValue
- number — controlled (value + onValueChange(next)) or uncontrolled (defaultValue).
- min / max / step
- Bound and quantise the value (default 0–100, step 1). step=5 snaps to fives.
- showValue
- boolean — render the current value (slider__value) beside the track. Off by default.
- onValueChange
- Fires continuously while dragging. (onValueCommitted fires once on release — use it if continuous updates are costly.)
- disabled
- Locks the thumb and dims the track.
- aria-label
- Required for a11y — the slider has no intrinsic label.
NumberField (NumberInput)
A numeric entry with −/+ stepper buttons, typed input, and optional prefix/suffix affixes (a $ or a unit). min/max/step clamp and step the value; the value is number | null (null when the field is cleared). Exported as NumberInput. The text input takes its own props via inputProps — that's where the aria-label goes.
value: 3
- value / defaultValue
- number | null — controlled (value + onValueChange) or uncontrolled (defaultValue). onValueChange hands back number | null (null = cleared).
- min / max / step
- Clamp and step the value; the −/+ buttons and arrow keys move by step (default 1; step accepts a number or 'any').
- prefix / suffix
- ReactNode affixes rendered inside the field (e.g. prefix="$", suffix="mi").
- invalid
- boolean — sets data-invalid on the group for the error styling. Pair with your own message + aria wiring.
- inputProps
- Props forwarded to the inner <input> — this is where aria-label, inputMode, placeholder etc. go (the stepper buttons already carry their own labels).
- disabled
- Disables the input and both stepper buttons.
SearchInput
A controlled search box with a leading magnifier and a clear (×) button that appears once there's a value. It is fully controlled — value and onValueChange are required (the wrapper calls onValueChange('') when the × is clicked). Use it for the live filter above a list; the faceted FilterBar is the heavier, multi-condition cousin.
query: “transport”
- value / onValueChange
- Both REQUIRED — it's a controlled input. onValueChange(next: string) fires on every keystroke and on clear (with "").
- clear button
- Auto-shown when value is non-empty; clicking it calls onValueChange('').
- invalid
- boolean — sets data-invalid / aria-invalid for the error treatment.
- placeholder
- Default “Search…”. Override per use.
- disabled
- Dims the field and blocks input.
- label it
- Pass aria-label (the input is type=search with no visible label of its own).
Select (accessible)
The Base-UI Select — a fully-styled, keyboard-accessible replacement for a native <select> that matches the rest of the system (the native <Select> on the Forms page is the lighter, zero-JS option). items is a {value,label,disabled?}[]; name it with label or aria-label. Reach for the Combobox instead once the list is long enough to want search.
value: northeast
- items
- SelectOption[] — { value: string; label: ReactNode; disabled?: boolean }. A per-item disabled greys just that option.
- value / defaultValue
- The selected value string — controlled (value + onValueChange(value)) or uncontrolled (defaultValue).
- placeholder
- Trigger text shown when nothing is selected.
- label / aria-label
- Required for a11y unless an external <label> points at it — label sets the trigger's accessible name.
- disabled / name
- disabled inerts the whole control; name identifies it on form submit.
- Select vs native vs Combobox
- Use this for a styled, bounded set; the native <Select> for the lightest zero-JS option; the Combobox once you need search.
DateInput (DatePicker)
A styled NATIVE date input (an <input type='date'>, exported as DatePicker) — you get the OS date picker, keyboard entry and locale formatting for free, with the field skinned to match the rest of the system. Value is the ISO yyyy-mm-dd string; all native date attributes (value/defaultValue/min/max/disabled) pass straight through, plus an invalid flag.
value: 2026-07-04
- value / defaultValue
- ISO yyyy-mm-dd string. Controlled via value + onChange (a native input event — read e.target.value); defaultValue for uncontrolled.
- min / max
- Native date bounds — clamp the pickable range (also yyyy-mm-dd strings).
- invalid
- boolean — sets aria-invalid for the error styling.
- disabled
- Standard native disable.
- why native
- No date-fns / no JS calendar component — the platform picker is accessible, localised and free. DatePicker is the styling-only wrapper.
- label it
- Pass aria-label or bind a <label htmlFor> — the input has no built-in label.
FileUpload
A single-file picker from @phauna/ds/files — a labelled trigger that opens the OS file dialog and calls onFile with the chosen File (or null when cleared). The DS owns the UI; the APP owns what to do with the File (validate / upload). For multi-file drag-and-drop with progress, the files module also ships Dropzone / Uploader / FileBrowser.
picked: —
- label
- ReactNode — the trigger's label / call-to-action.
- onFile
- (file: File | null) => void. Fires with the picked File, or null when the selection is cleared. The app decides what happens next (no upload is wired in the DS).
- accept
- Standard accept string (e.g. image/*, application/pdf) — narrows the OS file dialog.
- disabled
- Inerts the picker.
- single vs many
- FileUpload is one file. For multi-file DnD + per-file progress use Uploader / Dropzone / FileBrowser from @phauna/ds/files (the app supplies the endpoint).
Putting it together — a volunteer availability form
A realistic composition wiring several advanced controls into one onboarding step: a Combobox for the team, a Select for the region, a CheckboxGroup of skills, a Slider for travel radius, a NumberInput for foster spots, a DatePicker for the start date, a RadioGroup for contact preference, and a Switch for SMS. Every control is named via a wrapping label / fieldset legend / aria-label, and the footer follows the primary + ghost pairing. Presentational only — the submit is prevented.
- labelling
- Each control sits in a wrapping <label> (for the single inputs) or a <fieldset> with a <legend> (for the groups), so every control has a visible, associated name.
- controlled state
- The island holds one piece of state per control; this server page never touches state — it just mounts the island.
- footer
- Right-aligned primary (Save) + ghost (Cancel) — the same pairing rule as the Buttons page.
- no submit
- onSubmit is preventDefault'd — the styleguide never posts. Wire a real handler in the feature page.