Pickers
Choices too long to scan, and dates. Each picker takes the same label, hint, and error as a Field and looks the same, and each submits plain values with a form.
Combobox
type to filter · one value
Type a city.
Choose a time zone.
Value: Europe/Istanbul
The query is not the value. Typing only filters; the value changes when an option is chosen, and Escape restores the chosen label.
Source src/lib/components/forms/combobox/doc.ts · src/lib/components/forms/combobox/ChoicePicker.svelte · src/lib/components/forms/combobox/combobox.module.css · src/lib/components/forms/_shared/picker.module.css
src/lib/components/forms/combobox/doc.ts
/**
* Combobox — one value from a long list, found by typing.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Every picker takes the same field props as Field — label (REQUIRED), hint,
* error, required — plus disabled, readOnly, id, and class, and renders them
* the same way: label above, error then hint below.
*
* # Shape
*
* options { value, label, description?, disabled? }[], REQUIRED
* value? string | null (controlled)
* defaultValue? string | null (uncontrolled)
* onValueChange? called with the chosen value, or null when cleared
* placeholder?, name?, form?, emptyMessage?
*
* # Behaviour
*
* R1 Typing filters the options by label; the typed text is only a query and
* is never submitted or reported as the value.
* R2 Arrow keys move through matches, Enter chooses, Escape closes and
* restores the chosen label. The open button shows every option.
* R3 A chosen value can be cleared with a Clear button named "Clear
* <label>".
* R4 With no matches it says so in `emptyMessage` (default "No matching
* options."), never an empty box.
* R5 The control is the md control height (forms R1); disabled options are
* shown but cannot be chosen.
* R6 Inside a form it submits the chosen value under `name`.
*
* # Combobox or Select?
*
* A Select suits a short list the user can scan. When the list is long enough
* that people would rather type, use a Combobox.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* React: react-aria's ComboBox. Svelte: bits-ui's Combobox, with the query
* kept separate from the value and a hidden input per value for R6.
*/
export {};src/lib/components/forms/combobox/ChoicePicker.svelte
<script lang="ts">
import { Combobox as Primitive } from 'bits-ui';
import { untrack } from 'svelte';
import { Check, ChevronDown, X } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import { labelVariants } from '../label';
import { formReset } from '../_shared/form-reset';
import field from '../_shared/picker.module.css';
import styles from './combobox.module.css';
import type { ComboboxProps, MultiSelectProps } from './types';
/* The engine behind Combobox and MultiSelect. The typed query only filters;
it never becomes a submitted value. */
type Props = (ComboboxProps & { mode: 'single' }) | (MultiSelectProps & { mode: 'multiple' });
let props: Props = $props();
const generated = $props.id();
const id = $derived(props.id ?? generated);
let local = $state.raw<string | readonly string[] | null>(
untrack(() => props.defaultValue ?? (props.mode === 'single' ? null : []))
);
let query = $state('');
let open = $state(false);
let input = $state<HTMLInputElement | null>(null);
const selected = $derived(props.value === undefined ? local : props.value);
const selectedIds = $derived(
Array.isArray(selected) ? selected : selected ? [selected as string] : []
);
const labelOf = (value: string | null | undefined) =>
props.options.find((option) => option.value === value)?.label ?? '';
const matches = $derived(
props.options.filter((option) =>
option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())
)
);
// Error first, then hint, as with Field.
const describedBy = $derived(
[props.error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
undefined
);
// Native form reset restores the input's DOM default after its event.
$effect(() => {
if (!input) return;
const initial = props.value === undefined ? props.defaultValue : selected;
input.defaultValue = props.mode === 'single' ? labelOf(initial as string | null) : '';
});
// Closing restores the chosen label (or clears the query for multiple).
$effect(() => {
if (!open && input)
input.value = props.mode === 'single' ? labelOf(selected as string | null) : '';
});
$effect(() => {
input?.setCustomValidity(
props.required && !props.disabled && !props.readOnly && !selectedIds.length
? 'Choose an option.'
: ''
);
});
function choose(next: string | string[]) {
if (props.disabled || props.readOnly) return;
local = next;
if (props.mode === 'single')
props.onValueChange?.(typeof next === 'string' && next ? next : null);
else props.onValueChange?.(Array.isArray(next) ? next : next ? [next] : []);
query = '';
}
const rootProps = $derived(
props.mode === 'single'
? {
type: 'single' as const,
value: typeof selected === 'string' ? selected : '',
onValueChange: (next: string) => choose(next)
}
: {
type: 'multiple' as const,
value: [...selectedIds],
onValueChange: (next: string[]) => choose(next)
}
);
const inputValue = $derived(
props.mode === 'single' && !open ? labelOf(selected as string | null) : query
);
function reset() {
if (props.value !== undefined) return;
local = props.defaultValue ?? (props.mode === 'single' ? null : []);
query = '';
open = false;
if (input) {
const label = props.mode === 'single' ? labelOf(local as string | null) : '';
input.defaultValue = label;
input.value = label;
}
}
function remove(key: string) {
choose(selectedIds.filter((value) => value !== key));
}
</script>
<div class={cn(field.field, props.class)} use:formReset={{ form: props.form, reset }}>
<div class={field.label}>
<label for={id} class={labelVariants({ variant: 'field' })}>{props.label}</label>
{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
</div>
{#if props.readOnly}
<div class={field.control}>
<input
{id}
class={styles.input}
readonly
value={props.mode === 'single' ? inputValue : ''}
aria-describedby={describedBy}
aria-invalid={props.error ? 'true' : undefined}
/>
</div>
{:else}
<Primitive.Root
{...rootProps}
bind:open
{inputValue}
disabled={props.disabled}
required={props.required}
onOpenChange={(value) => {
if (!value) query = '';
}}
>
<div class={field.control}>
<Primitive.Input
bind:ref={input}
{id}
class={styles.input}
placeholder={props.placeholder ?? 'Search options…'}
aria-describedby={describedBy}
aria-invalid={props.error ? 'true' : undefined}
oninput={(event) => {
query = event.currentTarget.value;
open = true;
}}
/>
{#if props.mode === 'single' && selectedIds.length && !props.disabled}
<button
type="button"
class={field.iconButton}
aria-label={`Clear ${props.label}`}
onclick={() => choose('')}><X aria-hidden="true" /></button
>
{/if}
<Primitive.Trigger class={field.iconButton} aria-label={`Show options for ${props.label}`}>
<ChevronDown aria-hidden="true" />
</Primitive.Trigger>
</div>
<Primitive.Portal>
<Primitive.Content
class={cn(surface.elevated, field.popover, styles.popover)}
sideOffset={6}
>
<Primitive.Viewport class={styles.list}>
{#each matches as option (option.value)}
<Primitive.Item
value={option.value}
label={option.label}
disabled={option.disabled}
class={styles.option}
>
<div class={styles.optionText}>
<span>{option.label}</span>
{#if option.description}
<span class={styles.description}>{option.description}</span>
{/if}
</div>
<Check
aria-hidden="true"
class={styles.check}
style={`visibility:${selectedIds.includes(option.value) ? 'visible' : 'hidden'}`}
/>
</Primitive.Item>
{:else}
<p class={styles.empty}>
{props.emptyMessage ??
(props.options.length ? 'No matching options.' : 'No options available.')}
</p>
{/each}
</Primitive.Viewport>
</Primitive.Content>
</Primitive.Portal>
</Primitive.Root>
{/if}
{#if props.mode === 'multiple' && selectedIds.length}
{#if props.disabled || props.readOnly}
<ul class={styles.tags} aria-label={`Selected ${props.label}`}>
{#each selectedIds as key (key)}<li class={styles.tag}>{labelOf(key) || key}</li>{/each}
</ul>
{:else}
<div class={styles.tags} role="grid" aria-label={`Selected ${props.label}`}>
{#each selectedIds as key (key)}
<div
class={styles.tag}
role="row"
tabindex="0"
aria-label={labelOf(key) || key}
onkeydown={(event) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
const row = event.currentTarget;
const next = row.nextElementSibling ?? row.previousElementSibling;
remove(key);
if (next instanceof HTMLElement) next.focus();
else input?.focus();
}
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault();
const next =
event.key === 'ArrowRight'
? event.currentTarget.nextElementSibling
: event.currentTarget.previousElementSibling;
if (next instanceof HTMLElement) next.focus();
}
}}
>
<div role="gridcell">
<span>{labelOf(key) || key}</span>
<button
type="button"
class={styles.remove}
aria-label={`Remove ${labelOf(key) || key}`}
onclick={() => remove(key)}><X aria-hidden="true" /></button
>
</div>
</div>
{/each}
</div>
{/if}
{/if}
{#if props.name}
{#each selectedIds as key (key)}
<input
type="hidden"
name={props.name}
value={key}
form={props.form}
disabled={props.disabled}
/>
{/each}
{/if}
{#if props.error}<p id={`${id}-error`} class={field.error}>{props.error}</p>{/if}
{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>src/lib/components/forms/combobox/combobox.module.css
@layer primitive {
.input {
min-width: 0;
height: calc(var(--control-md) - 2px);
flex: 1;
padding: 0 var(--space-5);
border: 0;
background: transparent;
color: var(--ink);
font: inherit;
/* The control draws the focus ring for the whole group. */
outline: none;
}
.input::placeholder {
color: var(--ink-3);
}
/* react-aria's value wrapper; it must not take part in layout. */
.clear {
display: contents;
}
.selection:empty {
display: none;
}
.popover {
width: var(--trigger-width, var(--bits-combobox-anchor-width));
min-width: min(14rem, calc(100vw - var(--space-6)));
}
.list {
max-height: min(
18rem,
var(--available-height, var(--bits-combobox-content-available-height, 18rem))
);
overflow: auto;
padding: var(--space-2);
outline: none;
}
.option {
display: flex;
min-height: var(--control-md);
align-items: center;
justify-content: space-between;
gap: var(--space-5);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-1);
color: var(--ink);
font-size: var(--text-control, var(--text-13));
cursor: pointer;
outline: none;
}
/* Highlight follows pointer and keyboard: react-aria says data-focused,
bits-ui says data-highlighted. */
.option:is([data-focused], [data-highlighted]) {
background: var(--surface-hover-2);
}
.option[data-selected] {
font-weight: var(--weight-medium);
}
.option[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.optionText {
display: grid;
min-width: 0;
gap: var(--space-1);
overflow-wrap: anywhere;
}
.description {
color: var(--ink-3);
font-size: var(--text-12);
}
.check {
width: 14px;
height: 14px;
flex: none;
color: var(--accent);
}
.empty {
margin: 0;
padding: var(--space-5);
color: var(--ink-3);
font-size: var(--text-12);
}
/* MultiSelect's chosen values, below the control. */
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.tag {
display: inline-flex;
max-width: 100%;
min-height: 26px;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-1) 0 var(--space-4);
border: 1px solid var(--accent-line);
border-radius: var(--radius-pill);
background: var(--accent-tint);
color: var(--ink);
font-size: var(--text-12);
overflow-wrap: anywhere;
}
.tag > [role='gridcell'] {
display: contents;
}
.tag:is(:focus-visible, [data-focus-visible]) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.remove {
display: inline-grid;
width: 22px;
height: 22px;
flex: none;
place-items: center;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--ink-2);
cursor: pointer;
}
.remove svg {
width: 12px;
height: 12px;
}
.remove:hover {
background: var(--surface-hover-2);
color: var(--ink);
}
.remove:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
}src/lib/components/forms/_shared/picker.module.css
@layer primitive {
/* Shared by React (react-aria) and Svelte (bits-ui). Native pseudo-classes
where possible; where a library state is needed, both libraries'
attribute names are listed. */
.field {
display: grid;
min-width: 0;
align-content: start;
gap: var(--space-3);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.required {
color: var(--crit);
}
.hint,
.error {
margin: 0;
font-size: var(--text-12);
line-height: var(--leading-snug);
}
.hint {
color: var(--ink-3);
}
.error {
color: var(--crit);
}
/* The visible control: an input, optional clear, and an open button. Its
height is the md control token (forms R1). */
.control {
display: flex;
box-sizing: border-box;
min-width: 0;
min-height: var(--control-md);
align-items: center;
gap: var(--space-1);
padding-right: var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-2);
background: var(--surface-panel);
color: var(--ink);
font-size: var(--text-control, var(--text-13));
transition: border-color var(--dur-2) var(--ease);
}
.control:where(:hover) {
border-color: var(--line-heavy);
}
.control:has(:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.field[data-invalid] .control,
.control:has([aria-invalid='true']) {
border-color: var(--crit);
outline-color: var(--crit);
}
.field[data-disabled] .control,
.control:has(input:disabled) {
opacity: 0.45;
}
.field[data-readonly] .control,
.control:has(input[readonly]) {
background: var(--surface-sunk);
}
.iconButton {
display: inline-grid;
width: calc(var(--control-md) - 8px);
height: calc(var(--control-md) - 8px);
flex: none;
place-items: center;
padding: 0;
border: 0;
border-radius: var(--radius-1);
background: transparent;
color: var(--ink-3);
cursor: pointer;
}
.iconButton svg {
width: 14px;
height: 14px;
}
.iconButton:hover:not(:disabled) {
background: var(--surface-hover-2);
color: var(--ink);
}
.iconButton:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.iconButton:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.popover {
z-index: var(--z-palette);
max-width: calc(100vw - var(--space-6));
overflow: auto;
color: var(--ink);
}
}MultiSelect
several values · removable tags
Choose as many as apply.
Value: ["bug","perf"]
Tags are keyboard-removable. Focus a tag and press Backspace or Delete; focus moves to its neighbour.
Source src/lib/components/forms/multi-select/doc.ts · src/lib/components/forms/multi-select/MultiSelect.svelte
src/lib/components/forms/multi-select/doc.ts
/**
* MultiSelect — several values from a list, found by typing.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Every picker takes the same field props as Field — label (REQUIRED), hint,
* error, required — plus disabled, readOnly, id, and class, and renders them
* the same way: label above, error then hint below.
*
* # Shape
*
* As Combobox, except the value is a list:
*
* value?, defaultValue? string[]
* onValueChange? called with the new list
*
* # Behaviour
*
* R1 Choosing an option adds it; choosing it again removes it. The list stays
* open while choosing.
* R2 Chosen values show below the control as tags, in the order chosen. Each
* tag has a remove button named "Remove <label>"; Backspace or Delete on a
* focused tag removes it and focus moves to a neighbour.
* R3 When disabled or read-only, the tags are a plain list with no remove
* buttons.
* R4 Inside a form it submits one entry per chosen value under `name`.
*/
export {};src/lib/components/forms/multi-select/MultiSelect.svelte
<script lang="ts">
import ChoicePicker from '../combobox/ChoicePicker.svelte';
import type { MultiSelectProps } from '../combobox/types';
/* Several values from a searchable list; chosen values show as removable
tags below the control. */
let props: MultiSelectProps = $props();
</script>
<ChoicePicker {...props} mode="multiple" />DatePicker
YYYY-MM-DD · min · max · unavailable
Weekdays only, in October 2026.
Choose a due date.
Value: 2026-10-05
Dates are strings, not Date objects. A value is "2026-10-05" in
and out, so it never shifts by a day in another timezone.
Source src/lib/components/forms/date-picker/doc.ts · src/lib/components/forms/date-picker/DatePicker.svelte · src/lib/components/forms/date-picker/DateCalendar.svelte · src/lib/components/forms/date-picker/date-value.ts · src/lib/components/forms/date-picker/date-picker.module.css
src/lib/components/forms/date-picker/doc.ts
/**
* DatePicker — one calendar date.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Every picker takes the same field props as Field — label (REQUIRED), hint,
* error, required — plus disabled, readOnly, id, and class, and renders them
* the same way: label above, error then hint below.
*
* # Shape
*
* value?, defaultValue? "YYYY-MM-DD" | null
* onValueChange? called with "YYYY-MM-DD", or null when cleared
* minDate?, maxDate? "YYYY-MM-DD"
* isDateUnavailable? (date: "YYYY-MM-DD") => boolean
* locale? display locale, default "en-GB"
* name?, form?
*
* # Behaviour
*
* R1 Values are calendar dates as "YYYY-MM-DD" strings, never Date objects,
* so a date never shifts by a day across timezones. A malformed string is
* an error, not a guess.
* R2 The date is typed in segments (day, month, year) in the locale's order,
* each changeable with the arrow keys, or chosen from a calendar opened by
* a button named "Choose <label>".
* R3 In the calendar, arrow keys move by day, Page Up and Page Down by month,
* Home and End to the week's ends. Today is outlined, the chosen day is
* filled, and dates outside min/max are disabled.
* R4 Unavailable dates are struck through and cannot be chosen; a typed
* unavailable date is reported as an error.
* R5 A chosen date can be cleared with a button named "Clear <label>".
* R6 Inside a form it submits "YYYY-MM-DD" under `name`.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* React: react-aria's DatePicker with a Gregorian calendar forced through
* I18nProvider. Svelte: bits-ui's DatePicker; a visually hidden native date
* input carries the form value and native validation, and the calendar adds
* the Page Up/Down and Home/End keys bits-ui does not have.
*/
export {};src/lib/components/forms/date-picker/DatePicker.svelte
<script module lang="ts">
import type { DateControlProps } from './date-value';
export type DatePickerProps = DateControlProps & {
/** A YYYY-MM-DD string, or null for no date. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
name?: string;
};
</script>
<script lang="ts">
import { today, type DateValue } from '@internationalized/date';
import { DatePicker as Primitive, Portal } from 'bits-ui';
import { untrack } from 'svelte';
import { CalendarDays, X } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import { labelVariants } from '../label';
import CalendarFormValue from '../_shared/CalendarFormValue.svelte';
import { formReset } from '../_shared/form-reset';
import field from '../_shared/picker.module.css';
import DateCalendar from './DateCalendar.svelte';
import { calendarDate, dateConstraints } from './date-value';
import styles from './date-picker.module.css';
// The rule cannot see props read as `props.x`; every prop here is used.
// eslint-disable-next-line svelte/no-unused-props
let props: DatePickerProps = $props();
const generated = $props.id();
const id = $derived(props.id ?? generated);
let local = $state.raw(untrack(() => props.defaultValue ?? null));
let open = $state(false);
let validationError = $state<string | null>(null);
const error = $derived(props.error ?? validationError);
const chosen = $derived(props.value === undefined ? local : props.value);
const calendar = $derived(chosen ? calendarDate(chosen) : undefined);
const constraints = $derived(dateConstraints(props));
let placeholder = $state<DateValue>(
untrack(() => calendar ?? constraints.minValue ?? today('UTC'))
);
const describedBy = $derived(
[error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
undefined
);
function clear() {
if (props.disabled || props.readOnly) return;
local = null;
props.onValueChange?.(null);
}
function reset() {
if (props.value !== undefined) return;
local = props.defaultValue ?? null;
open = false;
}
</script>
<div class={cn(field.field, props.class)} use:formReset={{ reset, form: props.form }}>
<Primitive.Root
preventDeselect
bind:placeholder
value={calendar}
onValueChange={(next) => {
const value = next?.toString() ?? null;
local = value;
props.onValueChange?.(value);
}}
bind:open
onOpenChange={(value) => {
if (value) placeholder = calendar ?? placeholder;
}}
{...constraints}
isDateDisabled={constraints.isDateUnavailable}
locale={props.locale ?? 'en-GB'}
disabled={props.disabled}
readonly={props.readOnly}
required={props.required}
granularity="day"
weekdayFormat="short"
calendarLabel={props.label}
errorMessageId={error ? `${id}-error` : undefined}
>
<div class={field.label}>
<Primitive.Label class={labelVariants({ variant: 'field' })}>{props.label}</Primitive.Label>
{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
</div>
<div class={field.control}>
<div class={styles.inputs}>
<Primitive.Input
{id}
class={styles.input}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
>
{#snippet children({ segments })}
{#each segments as segment, index (`${segment.part}:${index}`)}
<Primitive.Segment part={segment.part} class={styles.segment}
>{segment.value}</Primitive.Segment
>
{/each}
{/snippet}
</Primitive.Input>
</div>
{#if chosen && !props.disabled && !props.readOnly}
<button
type="button"
class={field.iconButton}
aria-label={`Clear ${props.label}`}
onclick={clear}><X aria-hidden="true" /></button
>
{/if}
<Primitive.Trigger
class={field.iconButton}
aria-label={`Choose ${props.label}`}
disabled={props.disabled || props.readOnly}
>
<CalendarDays aria-hidden="true" />
</Primitive.Trigger>
</div>
<Portal>
<Primitive.Content
role="dialog"
class={cn(surface.elevated, field.popover, styles.popover, styles.dialog)}
sideOffset={6}
aria-label={`Choose ${props.label}`}
>
<DateCalendar
locale={props.locale ?? 'en-GB'}
minDate={props.minDate}
maxDate={props.maxDate}
onNavigate={(date) => (placeholder = date)}
/>
</Primitive.Content>
</Portal>
</Primitive.Root>
<CalendarFormValue
value={chosen ?? ''}
name={props.name}
form={props.form}
disabled={props.disabled}
readOnly={props.readOnly}
required={props.required}
min={props.minDate}
max={props.maxDate}
error={chosen && props.isDateUnavailable?.(chosen) ? 'Selected date unavailable.' : null}
focusId={id}
onValidationChange={(message) => (validationError = message)}
/>
{#if error}<p id={`${id}-error`} class={field.error}>{error}</p>{/if}
{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>src/lib/components/forms/date-picker/DateCalendar.svelte
<script lang="ts">
import { getDayOfWeek, type CalendarDate } from '@internationalized/date';
import { DatePicker, DateRangePicker } from 'bits-ui';
import { tick } from 'svelte';
import { ChevronLeft, ChevronRight } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import field from '../_shared/picker.module.css';
import { calendarDate } from './date-value';
import styles from './date-picker.module.css';
let {
range = false,
locale = 'en-GB',
minDate,
maxDate,
onNavigate
}: {
range?: boolean;
locale?: string;
minDate?: string;
maxDate?: string;
onNavigate: (date: CalendarDate) => void;
} = $props();
/* bits-ui moves by day and week; this adds Page Up/Down (month, or year
with Shift) and Home/End (the week's ends), the keys react-aria has. */
async function navigate(event: KeyboardEvent) {
const element = event.target;
if (
!(element instanceof HTMLElement) ||
!element.matches('[data-bits-day]') ||
!['PageDown', 'PageUp', 'Home', 'End'].includes(event.key)
)
return;
const value = element.dataset.value;
if (!value) return;
event.preventDefault();
const current = calendarDate(value);
const direction = event.key === 'PageUp' ? -1 : 1;
let target =
event.key === 'Home'
? current.subtract({ days: getDayOfWeek(current, locale) })
: event.key === 'End'
? current.add({ days: 6 - getDayOfWeek(current, locale) })
: current.add(event.shiftKey ? { years: direction } : { months: direction });
if (minDate && target.compare(calendarDate(minDate)) < 0) target = calendarDate(minDate);
if (maxDate && target.compare(calendarDate(maxDate)) > 0) target = calendarDate(maxDate);
const root = element.closest('[data-calendar-root],[data-range-calendar-root]');
onNavigate(target);
await tick();
const day =
root?.querySelector<HTMLElement>(
`[data-value="${target.toString()}"][aria-disabled="false"]`
) ?? root?.querySelector<HTMLElement>('[data-bits-day][aria-disabled="false"]');
day?.focus();
}
const Primitive = $derived(range ? DateRangePicker : DatePicker);
</script>
<Primitive.Calendar class={cn(styles.calendar, !range && styles.single)} onkeydown={navigate}>
{#snippet children({ months, weekdays })}
<Primitive.Header class={styles.header}>
<Primitive.PrevButton class={field.iconButton} aria-label="Previous month">
<ChevronLeft aria-hidden="true" />
</Primitive.PrevButton>
<Primitive.Heading class={styles.heading} />
<Primitive.NextButton class={field.iconButton} aria-label="Next month">
<ChevronRight aria-hidden="true" />
</Primitive.NextButton>
</Primitive.Header>
{#each months as month (month.value.toString())}
<Primitive.Grid class={styles.grid}>
<Primitive.GridHead>
<Primitive.GridRow>
{#each weekdays as day, i (i)}<Primitive.HeadCell class={styles.weekday}
>{day}</Primitive.HeadCell
>{/each}
</Primitive.GridRow>
</Primitive.GridHead>
<Primitive.GridBody>
{#each month.weeks as week, i (i)}
<Primitive.GridRow>
{#each week as date (date.toString())}
<Primitive.Cell {date} month={month.value}>
<Primitive.Day class={styles.day} />
</Primitive.Cell>
{/each}
</Primitive.GridRow>
{/each}
</Primitive.GridBody>
</Primitive.Grid>
{/each}
{/snippet}
</Primitive.Calendar>
<p class={styles.help}>
{range ? 'Choose a start and end date.' : 'Choose a date.'} Arrow keys move between days.
</p>src/lib/components/forms/date-picker/date-value.ts
import { parseDate } from '@internationalized/date';
import type { PickerFieldProps } from '../_shared/picker-field';
export type DateRange = { start: string; end: string };
export type DateControlProps = PickerFieldProps & {
/** Display locale; values stay Gregorian YYYY-MM-DD strings. */
locale?: string;
form?: string;
minDate?: string;
maxDate?: string;
isDateUnavailable?: (date: string) => boolean;
};
/** Never goes through Date, UTC midnight, or toISOString, so a date cannot
* shift by a day in another timezone. */
export function calendarDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
throw new RangeError(`Expected YYYY-MM-DD, received ${value}`);
return parseDate(value);
}
export function calendarRange(value: DateRange) {
// A reversed typed range is editable form state, not a malformed date; the
// field's validation explains it instead of throwing on rerender.
return { start: calendarDate(value.start), end: calendarDate(value.end) };
}
/** The calendar blocks gaps; typed endpoints also need the interior checked. */
export function rangeAvailabilityError(
range: ReturnType<typeof calendarRange>,
isDateUnavailable?: DateControlProps['isDateUnavailable']
) {
if (!isDateUnavailable || range.start.compare(range.end) > 0) return null;
let date = range.start;
for (;;) {
if (isDateUnavailable(date.toString())) return 'The range includes unavailable dates.';
if (date.compare(range.end) >= 0) return null;
date = date.add({ days: 1 });
}
}
export function dateConstraints({ minDate, maxDate, isDateUnavailable }: DateControlProps) {
const minValue = minDate ? calendarDate(minDate) : undefined;
const maxValue = maxDate ? calendarDate(maxDate) : undefined;
if (minValue && maxValue && minValue.compare(maxValue) > 0)
throw new RangeError('Minimum date must not follow maximum date.');
return {
minValue,
maxValue,
isDateUnavailable: isDateUnavailable
? (date: { toString(): string }) => isDateUnavailable(date.toString())
: undefined
};
}src/lib/components/forms/date-picker/date-picker.module.css
@layer primitive {
.inputs {
display: flex;
min-width: 0;
flex: 1;
flex-wrap: wrap;
align-items: center;
gap: 0 var(--space-3);
padding-left: var(--space-5);
}
.input {
display: flex;
min-height: calc(var(--control-md) - 2px);
align-items: center;
font-variant-numeric: tabular-nums;
outline: none;
}
.segment {
padding: 0 1px;
border-radius: var(--radius-1);
caret-color: transparent;
outline: none;
}
.segment[data-placeholder] {
color: var(--ink-3);
}
.segment:is(:focus, [data-focused]) {
background: var(--accent-tint);
color: var(--ink);
}
.rangeSeparator {
color: var(--ink-3);
}
.popover {
width: 18.5rem;
}
.dialog {
padding: var(--space-5);
outline: none;
}
.calendar {
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.heading {
flex: 1;
margin: 0;
font-size: var(--text-body, var(--text-13));
font-weight: var(--weight-strong);
text-align: center;
}
.grid {
width: 100%;
border-collapse: separate;
border-spacing: 2px;
table-layout: fixed;
}
.weekday {
padding-block: var(--space-3);
color: var(--ink-3);
font-size: var(--text-11);
font-weight: var(--weight-strong);
text-align: center;
}
.day {
display: flex;
width: 100%;
aspect-ratio: 1;
align-items: center;
justify-content: center;
border-radius: var(--radius-1);
font-size: var(--text-12);
font-variant-numeric: tabular-nums;
cursor: pointer;
outline: none;
}
.day:hover:not([data-disabled], [data-unavailable]) {
background: var(--surface-hover-2);
}
/* A range's interior is tinted; its ends, and a single chosen day, are
filled. */
.day[data-selected] {
background: var(--accent-tint);
color: var(--ink);
}
.single .day[data-selected],
.day:is([data-selection-start], [data-selection-end]) {
background: var(--fill);
color: var(--fill-ink);
}
.day[data-today] {
box-shadow: inset 0 0 0 1px var(--accent);
font-weight: var(--weight-strong);
}
.day[data-disabled] {
opacity: 0.35;
cursor: default;
}
.day[data-unavailable] {
color: var(--ink-3);
text-decoration: line-through;
cursor: default;
}
.day[data-outside-month] {
visibility: hidden;
}
.day:is(:focus-visible, [data-focus-visible]) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.help {
margin: var(--space-4) 0 0;
color: var(--ink-3);
font-size: var(--text-12);
line-height: var(--leading-snug);
}
}DateRangePicker
start · end · no unavailable days inside
Weekends cannot be inside the range.
Value: {"start":"2026-10-05","end":"2026-10-09"}
Source src/lib/components/forms/date-range-picker/doc.ts · src/lib/components/forms/date-range-picker/DateRangePicker.svelte
src/lib/components/forms/date-range-picker/doc.ts
/**
* DateRangePicker — a start date and an end date.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Every picker takes the same field props as Field — label (REQUIRED), hint,
* error, required — plus disabled, readOnly, id, and class, and renders them
* the same way: label above, error then hint below.
*
* # Shape
*
* As DatePicker, except:
*
* value?, defaultValue? { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } | null
* onValueChange? called only when both ends are set
* startName?, endName? form names for the two ends
*
* # Behaviour
*
* R1 Two segmented inputs, start and end, in one control; the calendar
* selects the start with the first click and the end with the second.
* R2 The range's ends are filled and its interior tinted.
* R3 A range may not contain an unavailable date: while choosing the end,
* dates that would span one are disabled, and a typed range that spans
* one is reported as "The range includes unavailable dates."
* R4 The value is reported only when complete; a half-chosen range is state
* inside the picker.
*/
export {};src/lib/components/forms/date-range-picker/DateRangePicker.svelte
<script module lang="ts">
import type { DateControlProps, DateRange } from '../date-picker/date-value';
export type DateRangePickerProps = DateControlProps & {
value?: DateRange | null;
defaultValue?: DateRange | null;
onValueChange?: (value: DateRange | null) => void;
startName?: string;
endName?: string;
};
</script>
<script lang="ts">
import { today, type DateValue } from '@internationalized/date';
import { DateRangePicker as Primitive, Portal } from 'bits-ui';
import { untrack } from 'svelte';
import { CalendarDays, X } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import surface from '../../surface.module.css';
import { labelVariants } from '../label';
import CalendarFormValue from '../_shared/CalendarFormValue.svelte';
import { formReset } from '../_shared/form-reset';
import field from '../_shared/picker.module.css';
import DateCalendar from '../date-picker/DateCalendar.svelte';
import {
calendarDate,
calendarRange,
dateConstraints,
rangeAvailabilityError
} from '../date-picker/date-value';
import styles from '../date-picker/date-picker.module.css';
// The rule cannot see props read as `props.x`; every prop here is used.
// eslint-disable-next-line svelte/no-unused-props
let props: DateRangePickerProps = $props();
const generated = $props.id();
const id = $derived(props.id ?? generated);
let local = $state.raw(untrack(() => props.defaultValue ?? null));
let open = $state(false);
let startError = $state<string | null>(null);
let endError = $state<string | null>(null);
const error = $derived(props.error ?? startError ?? endError);
/* A half-chosen range lives here until both ends are set (doc.ts R4). */
let candidate = $state.raw<
| ReturnType<typeof calendarRange>
| { start: undefined; end: undefined }
| { start: ReturnType<typeof calendarRange>['start']; end: undefined }
| null
>(null);
const chosen = $derived(props.value === undefined ? local : props.value);
const calendar = $derived(chosen ? calendarRange(chosen) : { start: undefined, end: undefined });
const constraints = $derived(dateConstraints(props));
let placeholder = $state<DateValue>(
untrack(() => calendar?.start ?? constraints.minValue ?? today('UTC'))
);
const describedBy = $derived(
[error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
undefined
);
function clear() {
if (props.disabled || props.readOnly) return;
local = null;
props.onValueChange?.(null);
}
function reset() {
// bits-ui clears its own fields on form reset, which leaves an empty
// candidate behind; drop it so the restored value shows.
candidate = null;
if (props.value !== undefined) return;
local = props.defaultValue ?? null;
open = false;
}
</script>
<div class={cn(field.field, props.class)} use:formReset={{ reset, form: props.form }}>
<Primitive.Root
preventDeselect
bind:placeholder
value={candidate ?? calendar}
onValueChange={(next) => {
candidate = next as typeof candidate;
if (!next.start || !next.end) return;
const value = { start: next.start.toString(), end: next.end.toString() };
local = value;
props.onValueChange?.(value);
}}
onOpenChange={(value) => {
candidate = null;
if (value) placeholder = calendar.start ?? placeholder;
}}
bind:open
{...constraints}
isDateDisabled={(date) => {
if (constraints.isDateUnavailable?.(date)) return true;
// While choosing the end, block ends that would span an unavailable day.
if (!candidate?.start || candidate.end) return false;
const anchor = calendarDate(candidate.start.toString());
const endpoint = calendarDate(date.toString());
return Boolean(
rangeAvailabilityError(
anchor.compare(endpoint) <= 0
? { start: anchor, end: endpoint }
: { start: endpoint, end: anchor },
props.isDateUnavailable
)
);
}}
locale={props.locale ?? 'en-GB'}
disabled={props.disabled}
readonly={props.readOnly}
required={props.required}
granularity="day"
weekdayFormat="short"
calendarLabel={props.label}
errorMessageId={error ? `${id}-error` : undefined}
validate={(next) =>
next.start && next.end
? (rangeAvailabilityError(
next as ReturnType<typeof calendarRange>,
props.isDateUnavailable
) ?? undefined)
: undefined}
>
<div class={field.label}>
<Primitive.Label class={labelVariants({ variant: 'field' })}>{props.label}</Primitive.Label>
{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
</div>
<div class={field.control}>
<div class={styles.inputs}>
<Primitive.Input
type="start"
{id}
class={styles.input}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
>
{#snippet children({ segments })}
{#each segments as segment, index (`${segment.part}:${index}`)}
<Primitive.Segment part={segment.part} class={styles.segment}
>{segment.value}</Primitive.Segment
>
{/each}
{/snippet}
</Primitive.Input>
<span class={styles.rangeSeparator} aria-hidden="true">–</span>
<Primitive.Input
type="end"
id={`${id}-end`}
class={styles.input}
aria-label={`${props.label} end`}
aria-describedby={describedBy}
>
{#snippet children({ segments })}
{#each segments as segment, index (`${segment.part}:${index}`)}
<Primitive.Segment part={segment.part} class={styles.segment}
>{segment.value}</Primitive.Segment
>
{/each}
{/snippet}
</Primitive.Input>
</div>
{#if chosen && !props.disabled && !props.readOnly}
<button
type="button"
class={field.iconButton}
aria-label={`Clear ${props.label}`}
onclick={clear}><X aria-hidden="true" /></button
>
{/if}
<Primitive.Trigger
class={field.iconButton}
aria-label={`Choose ${props.label}`}
disabled={props.disabled || props.readOnly}
>
<CalendarDays aria-hidden="true" />
</Primitive.Trigger>
</div>
<Portal>
<Primitive.Content
role="dialog"
class={cn(surface.elevated, field.popover, styles.popover, styles.dialog)}
sideOffset={6}
aria-label={`Choose ${props.label}`}
>
<DateCalendar
locale={props.locale ?? 'en-GB'}
range
minDate={props.minDate}
maxDate={props.maxDate}
onNavigate={(date) => (placeholder = date)}
/>
</Primitive.Content>
</Portal>
</Primitive.Root>
<CalendarFormValue
value={candidate ? (candidate.start?.toString() ?? '') : (chosen?.start ?? '')}
name={props.startName}
form={props.form}
disabled={props.disabled}
readOnly={props.readOnly}
required={props.required}
min={props.minDate}
max={props.maxDate}
error={chosen ? rangeAvailabilityError(calendarRange(chosen), props.isDateUnavailable) : null}
focusId={id}
onValidationChange={(message) => (startError = message)}
/>
<CalendarFormValue
value={candidate ? (candidate.end?.toString() ?? '') : (chosen?.end ?? '')}
name={props.endName}
form={props.form}
disabled={props.disabled}
readOnly={props.readOnly}
required={props.required}
min={chosen?.start ?? props.minDate}
max={props.maxDate}
focusId={`${id}-end`}
onValidationChange={(message) => (endError = message)}
error={candidate?.start && !candidate.end ? 'Complete the date range.' : null}
/>
{#if error}<p id={`${id}-error`} class={field.error}>{error}</p>{/if}
{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>In a form
what actually gets submitted
Plain values only. The Combobox submits its value, not what was typed; the
MultiSelect submits one entry per tag; dates submit YYYY-MM-DD. Reset restores
the defaults.