registry:ui · Data Entry
date-picker — the zero-dep calendar popover
Form wave 2 — calendar popover over hand-rolled Date math (zero date libraries): single ISO string or {start,end} range with anchor/swap semantics, Monday-first grid, strict ISO parse/format/compare. Popover API panel (popover=auto + popovertarget) CSS-anchored under a select-style trigger; light dismiss/Escape/top-layer are native. Floating-surface law: ::after shadow layer (semi-transparent black/white) + @starting-style entry; variant='solid' | 'acrylic' | 'auto' (acrylic falls back to solid under prefers-reduced-transparency; the terminal bezel keeps its identity through the jx-surface fill props).
$npx jixoai-ui add date-picker
https://ui.jixoai.com/r/date-picker.json
overview
Overview
A select-style trigger over a native Popover API panel — zero date libraries, zero native input[type=date]: selection state, popover orchestration and calendar math are three orthogonal pieces.
The picker is deliberately not a native <input type="date">: a
select-style trigger (the jx-html-input law's box) opens a Popover API panel — popover="auto" wired with popovertarget, so light dismiss,
Escape, one-at-a-time and top-layer rendering are the browser's. The popover FAMILY is
not in the chain; the panel speaks the shared jx-surface vocabulary and slides through
the shared surface-motion kernel (one WAAPI-animated --jx-p number).
Three orthogonal pieces compose: the selection state lives here (single commits an ISO YYYY-MM-DD string; range binds a { start, end } pair with
anchor / close / swap-when-backwards semantics; showTime widens the canonical
value to a local wall-clock datetime that the grid and the TimeStepper edit one half each);
the calendar fragment (nav + grid + keyboard cursor) is the embeddable Calendar, remounted per open so the view resets to the committed anchor; the
ISO math lives in calendar-math.ts. Those two fragments are the composed
consumers: they mount inside the field wrapper and inherit its density scope.
The quick-pick lane splits the same way — the LANE is the component's, the ENTRIES are
yours — and a preset activation rides the exact grid-pick pipeline (commit + close). The
committed value is canonical ISO forever; format and locale own
the display only. All eight universal axes resolve on the field wrapper — density is the
one axis that repaints (through its named rung), theme re-themes it partially, and six
stamp-and-supply (per-axis below). The shared grammar lives on the universal props page.
usage
Usage
Bind value (single) or range (range); format is display-only, bounds are inclusive ISO days.
const deploy = $state('2026-08-24');
const sprint = $state({ start: '2026-08-10', end: '2026-08-16' });
const at = $state('2026-08-30T14:05'); // canonical datetime
<!-- single: commits ISO "YYYY-MM-DD" — format only changes the display -->
<DatePicker label="deploy date" bind:value={deploy} />
<DatePicker label="review" format="locale" bind:value={deploy} />
<!-- inclusive bounds; outside days render disabled -->
<DatePicker label="windowed" min="2026-08-01" max="2026-08-31" bind:value={deploy} />
<!-- range: first click anchors, second closes (backwards swaps) -->
<DatePicker label="sprint" mode="range" bind:range={sprint} />
<!-- presets: the lane is the component's, the entries are yours —
plain dates in single mode, {start,end} pairs in range mode -->
<DatePicker
label="quick picks"
mode="range"
presets={[
{ label: 'today', value: todayIso() },
{ label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
]}
bind:range={sprint}
/>
<!-- showTime (v1 single-mode only; range + time is a type error):
the canonical value becomes "YYYY-MM-DDTHH:mm" local wall-clock —
the grid mutates the date part, the stepper the time part -->
<DatePicker label="at" showTime format="locale" bind:value={at} />
<!-- isDisabled: your days wear the outside-day law (visible,
not-allowed, uncommittable); the arrow walk skips them -->
<DatePicker label="weekdays only" isDisabled={(iso) => [0, 6].includes(new Date(iso + 'T00:00:00Z').getUTCDay())} bind:value={deploy} />date-picker
A zero-dependency calendar popover over hand-rolled Date math — popover='auto' gives light dismiss and the top layer; single mode commits ISO 'YYYY-MM-DD'.
<DatePicker
label="deploy date"
format="iso"
bind:value
/>date-picker
The calendar
No native <input type='date'>, no date library: the panel is a Popover API surface — popover='auto' wired with popovertarget, so light dismiss, Escape, one-at-a-time, and top-layer rendering are the browser's — over hand-rolled calendar math (leap years, month lengths, Monday-first grid offsets, strict ISO parse/format/compare). single commits 'YYYY-MM-DD'; range binds a start/end pair with anchor / close / swap-when-backwards semantics and a third click re-anchoring. The grid is one focus stop: ↑↓←→ walk the cursor across month boundaries (the view follows), Enter commits, Escape is native. format changes the display only — the value stays ISO forever.
date-picker · catalogue
2026-08-24—2026-08-10 ·
end: 2026-08-16audit date is required
— → —2026-08-30T14:05 · grid keeps the
time, stepper keeps the day<script lang="ts">
import DatePicker from '@ui/date-picker.svelte';
import type { DatePickerRange } from '@ui/date-picker.svelte';
import { addDays, todayIso } from '@ui/date-picker/calendar-math';
let deployDate = $state('2026-08-24');
let localeDate = $state<string | undefined>(undefined);
let windowedDate = $state<string | undefined>(undefined);
let auditDate = $state<string | undefined>(undefined);
let sprintRange = $state<DatePickerRange>({ start: '2026-08-10', end: '2026-08-16' });
let quickRange = $state<DatePickerRange | undefined>(undefined);
let datetimeValue = $state('2026-08-30T14:05');
let weekdayOnlyDate = $state<string | undefined>(undefined);
const sprintPresets = [
{ label: 'today', value: todayIso() },
{ label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
{ label: 'last 30', value: { start: addDays(todayIso(), -29), end: todayIso() } },
];
const weekendGuard = (iso: string): boolean => {
const dow = new Date(iso + 'T00:00:00Z').getUTCDay();
return dow === 0 || dow === 6;
};
</script>
<DatePicker label="deploy date" bind:value={deployDate} />
<DatePicker label="review (locale display)" format="locale" bind:value={localeDate} />
<DatePicker label="windowed (min/max)" min="2026-08-04" max="2026-09-16" bind:value={windowedDate} />
<DatePicker label="sprint (range)" mode="range" bind:range={sprintRange} />
<DatePicker label="audit date" error="audit date is required" bind:value={auditDate} />
<DatePicker label="quick picks (presets)" mode="range" presets={sprintPresets} bind:range={quickRange} />
<DatePicker label="date + time (showTime)" showTime format="locale" bind:value={datetimeValue} />
<DatePicker label="weekdays only (isDisabled)" isDisabled={weekendGuard} bind:value={weekdayOnlyDate} />Open one and keep typing: the panel is a terminal bezel like the Select dropdown, the
month label is font-nav uppercase with clamped ←/→ navigation, today reads a --primary border, selected days fill primary, and
range interiors wash at color-mix(--primary 14%, transparent). The trigger is
the Select trigger's paint — ↑/↓ on it opens the panel, focus restitutes on every
close path.
const deploy = $state('2026-08-24');
const sprint = $state({ start: '2026-08-10', end: '2026-08-16' });
const at = $state('2026-08-30T14:05'); // canonical datetime
<!-- single: commits ISO "YYYY-MM-DD" — format only changes the display -->
<DatePicker label="deploy date" bind:value={deploy} />
<DatePicker label="review" format="locale" bind:value={deploy} />
<!-- inclusive bounds; outside days render disabled -->
<DatePicker label="windowed" min="2026-08-01" max="2026-08-31" bind:value={deploy} />
<!-- range: first click anchors, second closes (backwards swaps) -->
<DatePicker label="sprint" mode="range" bind:range={sprint} />
<!-- presets: the lane is the component's, the entries are yours —
plain dates in single mode, {start,end} pairs in range mode -->
<DatePicker
label="quick picks"
mode="range"
presets={[
{ label: 'today', value: todayIso() },
{ label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
]}
bind:range={sprint}
/>
<!-- showTime (v1 single-mode only; range + time is a type error):
the canonical value becomes "YYYY-MM-DDTHH:mm" local wall-clock —
the grid mutates the date part, the stepper the time part -->
<DatePicker label="at" showTime format="locale" bind:value={at} />
<!-- isDisabled: your days wear the outside-day law (visible,
not-allowed, uncommittable); the arrow walk skips them -->
<DatePicker label="weekdays only" isDisabled={(iso) => [0, 6].includes(new Date(iso + 'T00:00:00Z').getUTCDay())} bind:value={deploy} />api
Props
Props from the DatePicker Props interface; value and range are bindable commit seams. The table renders from the GENERATED meta + curation (the union-alias ceiling: most type cells are honest corrections); variant keeps its own 'auto' — the literal-slot marker, not an axis seat.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
value bind | string | — | ISO "YYYY-MM-DD" (or canonical "YYYY-MM-DDTHH:mm" with showTime); single mode committed value. |
range bind | DatePickerRange | — | { start?, end? }; range mode committed value. |
| mode | 'single' | 'range' | 'single' | Commit mode for the calendar. |
| showTime | boolean | false | v1 single-mode ONLY — mode="range" + showTime is a type error. Canonical "YYYY-MM-DDTHH:mm" local wall-clock value; the TimeStepper row mutates the time part, the grid the date part; each preserves the other. |
| label | string | — | Field label; renders label[for] above the trigger. |
| error | string | — | Error text → aria-invalid + describedby + dashed trigger. |
| placeholder | string | 'Select date...' | Trigger text when nothing is committed. |
| min | string | — | ISO date; earlier days render disabled. |
| max | string | — | ISO date; later days render disabled. |
| format | 'iso' | 'locale' | 'iso' | Display format — the committed value stays canonical regardless. |
| locale | string | — | BCP 47 locale for the panel vocabulary + the 'locale' display format (Intl.DateTimeFormat); default = the page's <html lang>. |
| presets | DatePickerPreset[] | — | Quick-pick lane entries ({ label, value: ISO date | { start, end } }); activation rides the exact grid-pick pipeline (commit + close). Malformed values are dropped. |
| preset | Snippet<[DatePickerPreset]> | — | Snippet escape for per-entry rich content; default renders the label text. |
| isDisabled | (iso: string) => boolean | — | Consumer day predicate — true days wear the outside-day law (visible, not-allowed, uncommittable); the arrow walk skips them. |
| id | string | auto | Wired into label[for] / error[id]; auto-generated when omitted. |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | Floating-surface variant for the panel fill. |
| class | string | '' | Class passthrough. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on date-picker
date-picker resolves all eight axes through its family Defaults and stamps the carriers on the field wrapper (batch A of the explicit-props migration — migration-census.md). One axis repaints: density, through its NAMED rung (the wrapper's data-density scope re-bases the trigger's input-law channels). theme re-themes PARTIALLY — the declaring-selector grep splits the painted voices into raw (flip) and stylex-frozen (stay) halves, both named below. Six stamp-and-supply without touching the paint, recorded with negative-grep receipts instead of invented consumers. The composed consumers — the Calendar and TimeStepper fragments — mount inside the wrapper and inherit every scope.
Reading the table: Property is the axis, Type is the real carrier it stamps on the wrapper here, Default is the lane default — the named steps, number unit, and consumption on this family are in each description.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-size-effective; no family css reads it (grep receipt: zero --jx-size-effective readers in ui/date-picker/). The trigger rides the input law's fixed channels (--jx-hit/--jx-inset/--jx-leading) and the grid cells are 2rem structural tracks — nothing scales with this stamp. The §1 collision still applies: the axis owns the name, the trigger button never receives a native size attribute. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps --jx-shape-effective and --jx-radius-factor-effective; no family css reads them (grep receipt: zero readers in ui/date-picker/). The panel bezel's corner is border-radius: inherit (the jx-surface-body law) and the day cells are structural squares. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; no family css reads it (grep receipt: zero readers in ui/date-picker/). The panel bezel's corner is the CSS inherit chain (border-radius: inherit on .jx-surface-body), not a carrier computation — the concentric consumers are elsewhere (press-button, card). Number unit: px. |
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | CONSUMED — through the NAMED lane. The rung stamps data-density on the field wrapper and the scope block re-declares the channels the trigger's input law reads (--jx-hit, --jx-inset, --jx-gap, --jx-leading) INSIDE the scope — the trigger box, its rhythm and everything composed under the wrapper re-base together (the Calendar and TimeStepper fragments are the composed consumers). The NUMBER lane is inert here: it stamps --jx-density-coefficient on the wrapper, but the channels compose their coefficient at :root and the rung scopes — substitution runs at the declaring element, so nothing re-declares (the declaring-element law). auto stamps neither half. Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — stamps --jx-color-effective; no family css reads it (grep receipt: zero --jx-color-effective readers in ui/date-picker/). The hue that paints rides the theme's primary directly — the selected-day fill, the today border and the range wash read --primary/var(--jx-primary), never the hue carrier. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | PARTIAL re-theme on this family, measured — the drift ledger's declaring-selector grep decides voice by voice. FLIPS under the wrapper's .dark (raw tokens, re-declared per scope): the trigger box (--background/--border/--foreground via the jx-html-input law), its focus ring (--ring), the day/weekday ink (raw --terminal-foreground), the range interior wash (raw --primary in the color-mix), and the bezel fill (--popover). STAYS FROZEN (the stylex --jx-* aliases, substituted at :root): the selected-day fill and today border (--jx-primary pair + --jx-primary-foreground glyph), the placeholder/chevron ink (--jx-muted-foreground), the nav/stepper button ink (--jx-terminal-foreground), the lane seams (--jx-border). The demos below show both halves. light and system stamp nothing — tree inheritance. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-elevation-effective; no family css reads it. The panel's surface law reads a consumption PAIR — var(--jx-elevation-surface, var(--popover)) and var(--jx-elevation-shadow, none) — but date-picker never stamps that pair (its stampers are toast, terminal-card and press-button), so the lane lands inert on this family's own bezel. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-motion-effective; no family css reads it. The panel's real motion is the shared surface-motion kernel: WAAPI animates one --jx-p progress number and the css formulas derive every visible property from it — timings are kernel literals (--motion-150/--motion-100) with the prefers-reduced-motion kill. Number unit: coefficient. |
<!-- density: the named rung is the axis that repaints this
family — data-density lands on the field wrapper and the
trigger's input-law channels (--jx-hit/--jx-inset/--jx-gap)
re-base inside the scope -->
<DatePicker label="compact (sm rung)" density="small" />
<DatePicker label="touch (lg rung)" density="lg" />
<!-- theme: PARTIAL re-theme, measured — the trigger box (raw
--background/--border/--foreground) and the day ink flip under
the wrapper's .dark; the selected-day fill and today border ride
the frozen --jx-primary pair and keep the light profile -->
<DatePicker label="dark island" theme="dark" value="2026-08-24" />date-picker · the consumed lanes
Open either theme panel: the bezel, the trigger box and the day ink follow the
island's dark profile, while a committed day still fills with the frozen --jx-primary — the split the table names voice by voice.
<!-- density: the named rung is the axis that repaints this
family — data-density lands on the field wrapper and the
trigger's input-law channels (--jx-hit/--jx-inset/--jx-gap)
re-base inside the scope -->
<DatePicker label="compact (sm rung)" density="small" />
<DatePicker label="touch (lg rung)" density="lg" />
<!-- theme: PARTIAL re-theme, measured — the trigger box (raw
--background/--border/--foreground) and the day ink flip under
the wrapper's .dark; the selected-day fill and today border ride
the frozen --jx-primary pair and keep the light profile -->
<DatePicker label="dark island" theme="dark" value="2026-08-24" /><script lang="ts">
import DatePicker from '@ui/date-picker.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<DatePicker
label="responsive trigger"
density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
/>date-picker · query()
Media keys are min-width: below 40rem the base applies — the large rung, the touch-size trigger; at 40rem and wider the sm case wins and the trigger compacts to the pointer lane. Resize the window across 40rem and watch the box step.
<script lang="ts">
import DatePicker from '@ui/date-picker.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<DatePicker
label="responsive trigger"
density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
/>| Token | Default | Source |
|---|---|---|
| --jx-hit | 24 / 28 / 32 / 40 / 48px (2xs → lg) | density |
| --jx-inset | rung scale × coefficient | density |
| --jx-gap | rung scale × coefficient | density |
| --jx-leading | rung scale × coefficient | density |
| --jx-icon-calendar | the icon vocabulary slot | structural |
| Token | Default | Source |
|---|---|---|
| --background / --border / --foreground | FLIPS under .dark | structural |
| --ring | FLIPS under .dark | structural |
| --terminal-foreground (raw) | FLIPS under .dark | structural |
| --primary (raw) | FLIPS under .dark | structural |
| --popover | FLIPS under .dark | structural |
| --jx-primary / --jx-primary-foreground | FROZEN at :root | structural |
| --jx-muted-foreground | FROZEN at :root | structural |
| --jx-terminal-foreground | FROZEN at :root | structural |
| --jx-border | FROZEN at :root | structural |
a11y
Accessibility
The grid is one focus stop: arrows walk the cursor across month boundaries and skip disabled days, Enter commits, Escape and light dismiss are the platform's.
Keyboard
| Key | Action |
|---|---|
| ↑ ↓ ← → | On the trigger: open the panel; in the grid: walk the cursor across month boundaries (the view follows) — disabled days (min/max, isDisabled) are skipped |
| Enter / Space | Commit the focused day; open the panel from the trigger; preset lane buttons commit like a grid pick |
| Escape | Native popover dismiss — focus restitutes to the trigger on every close path |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-invalid | true | Set on the trigger when error is present |
| aria-describedby | {id}-error | References the validation message |
| role: grid | one focus stop | The calendar grid is a single tab stop with a roving day cursor |
| aria-disabled | true | Painted on disabled day cells (min/max bounds and isDisabled days) |