@ -15,27 +15,35 @@
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event : string ; family : string ; intent? : string ; at : number } ;
type TraceEntry = { event : string ; family : string ; intent? : string ; at : number } ;
type WeekStartControl = 'locale' | '0' | '1';
type WeekStartControl = 'locale' | '0' | '1';
type MinDateMode = 'none' | 'today' | 'custom';
type MaxDateMode = 'none' | 'custom';
const uix = getActiveUix();
const uix = getActiveUix();
type EmitSignal = Parameters< NonNullable < typeof uix . events > ['emit']>[0];
// Fixed anchor dates so the demo is deterministic across reloads. Same
// pattern as the date-range-picker demo (which this view mirrors), with
// presets letting consumers see the picker's behaviour under common
// real-world configurations.
const initialMonth = new CalendarDate(2026, 5, 1);
const initialMonth = new CalendarDate(2026, 5, 1);
const start = new CalendarDate(2026, 5, 8);
const sprintS tart = new CalendarDate(2026, 5, 8);
const end = new CalendarDate(2026, 5, 19);
const sprintE nd = new CalendarDate(2026, 5, 19);
const todayDate = new CalendarDate(2026, 5, 12);
const defaultMin = new CalendarDate(2026, 5, 1 );
const customMinDate = new CalendarDate(2026, 5, 5 );
const defaultMax = new CalendarDate(2026, 7, 31 );
const customMaxDate = new CalendarDate(2026, 7, 15 );
const invalidMax = new CalendarDate(2026, 4, 30 );
let tab = $state< Tab > ('live');
let tab = $state< Tab > ('live');
let trace = $state< TraceEntry [ ] > ([]);
let trace = $state< TraceEntry [ ] > ([]);
let stageRef = $state< HTMLElement | null > (null);
let stageRef = $state< HTMLElement | null > (null);
let value = $state< DateRange > ({ start , e nd } );
let value = $state< DateRange > ({ start : sprintStart , e nd: sprintE nd } );
let placeholder = $state< DateValue > (initialMonth);
let placeholder = $state< DateValue > (initialMonth);
let minDays = $state< number | undefined > (undefined);
let maxDays = $state< number | undefined > (14);
// Bounds + span constraints — text inputs are the cleanest UX (no
// hand-rolled "today | custom" modes). Each text becomes a DateValue
// via parseDateInput; presets reset both at once.
let minInput = $state(toDateInput(defaultMin));
let maxInput = $state(toDateInput(defaultMax));
let minDaysInput = $state('');
let maxDaysInput = $state('14');
let allowSingleDay = $state(true);
let allowSingleDay = $state(true);
let preventDeselect = $state(false);
let preventDeselect = $state(false);
let pagedNavigation = $state(false);
let pagedNavigation = $state(false);
@ -45,8 +53,6 @@
let disableDaysOutsideMonth = $state(false);
let disableDaysOutsideMonth = $state(false);
let disabled = $state(false);
let disabled = $state(false);
let readonly = $state(false);
let readonly = $state(false);
let minDateMode = $state< MinDateMode > ('custom');
let maxDateMode = $state< MaxDateMode > ('custom');
let weekStartsOn = $state< WeekStartControl > ('1');
let weekStartsOn = $state< WeekStartControl > ('1');
let locale = $state('en-US');
let locale = $state('en-US');
let size = $state< RangeCalendarSize > ('md');
let size = $state< RangeCalendarSize > ('md');
@ -60,12 +66,50 @@
const resolvedWeekStartsOn = $derived(
const resolvedWeekStartsOn = $derived(
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
);
);
const selectionMinValue = $derived(
const selectionMinValue = $derived(parseDateInput(minInput));
minDateMode === 'today' ? todayDate : minDateMode === 'custom' ? customMinDate : undefined
const selectionMaxValue = $derived(parseDateInput(maxInput));
);
const minDays = $derived(parseOptionalInt(minDaysInput));
const selectionMaxValue = $derived(maxDateMode === 'custom' ? customMaxDate : undefined);
const maxDays = $derived(parseOptionalInt(maxDaysInput));
const minDateLabel = $derived(selectionMinValue?.toString() ?? 'none');
const maxDateLabel = $derived(selectionMaxValue?.toString() ?? 'none');
function toDateInput(date: DateValue): string {
return [
String(date.year).padStart(4, '0'),
String(date.month).padStart(2, '0'),
String(date.day).padStart(2, '0')
].join('-');
}
function parseDateInput(text: string): DateValue | undefined {
if (!text) return undefined;
const [year, month, day] = text.split('-').map(Number);
if (!year || !month || !day) return undefined;
return new CalendarDate(year, month, day);
}
function parseOptionalInt(text: string): number | undefined {
if (!text) return undefined;
const value = Number.parseInt(text, 10);
return Number.isFinite(value) & & value > 0 ? value : undefined;
}
function setBoundsPreset(preset: 'none' | 'min' | 'max' | 'window' | 'invalid'): void {
if (preset === 'none') {
minInput = '';
maxInput = '';
} else if (preset === 'min') {
minInput = toDateInput(defaultMin);
maxInput = '';
} else if (preset === 'max') {
minInput = '';
maxInput = toDateInput(defaultMax);
} else if (preset === 'window') {
minInput = toDateInput(defaultMin);
maxInput = toDateInput(defaultMax);
} else if (preset === 'invalid') {
minInput = toDateInput(defaultMin);
maxInput = toDateInput(invalidMax);
}
}
const rangeLabel = $derived.by(() => {
const rangeLabel = $derived.by(() => {
if (!value.start & & !value.end) return 'empty';
if (!value.start & & !value.end) return 'empty';
@ -103,6 +147,8 @@
return `${ String ( d . getSeconds ()). padStart ( 2 , '0' )} .${ String ( d . getMilliseconds ()). padStart ( 3 , '0' )} `;
return `${ String ( d . getSeconds ()). padStart ( 2 , '0' )} .${ String ( d . getMilliseconds ()). padStart ( 3 , '0' )} `;
}
}
type EmitSignal = Parameters< NonNullable < typeof uix . events > ['emit']>[0];
function emitSelectStart(signal: EmitSignal) {
function emitSelectStart(signal: EmitSignal) {
void uix.events?.emit(signal);
void uix.events?.emit(signal);
}
}
@ -226,12 +272,17 @@
< RangeCalendar.Heading / >
< RangeCalendar.Heading / >
< RangeCalendar.NextButton aria-label = "Next month" > › < / RangeCalendar.NextButton >
< RangeCalendar.NextButton aria-label = "Next month" > › < / RangeCalendar.NextButton >
< / RangeCalendar.Header >
< / RangeCalendar.Header >
<!-- Multi - month layout: grids sit side - by - side horizontally via
`grid-auto-flow: column` (mirrors the DateRangePicker demo).
When numberOfMonths=1 this collapses to a single grid track. -->
< div class = "range-months" data-month-count = { months . length } >
{ #each months as month ( month . value . toString ())}
{ #each months as month ( month . value . toString ())}
< div class = "range-month-panel" >
< RangeCalendar.Grid >
< RangeCalendar.Grid >
< RangeCalendar.GridHead >
< RangeCalendar.GridHead >
< RangeCalendar.GridRow >
< RangeCalendar.GridRow >
{ # snippet children ( _props : RangeCalendarGridRowSnippetProps )}
{ # snippet children ( _props : RangeCalendarGridRowSnippetProps )}
{ #each weekdays as wd ( wd )}
{ #each weekdays as wd , i ( i )}
< RangeCalendar.HeadCell > { wd } </ RangeCalendar.HeadCell >
< RangeCalendar.HeadCell > { wd } </ RangeCalendar.HeadCell >
{ /each }
{ /each }
{ /snippet }
{ /snippet }
@ -251,7 +302,9 @@
{ /each }
{ /each }
< / RangeCalendar.GridBody >
< / RangeCalendar.GridBody >
< / RangeCalendar.Grid >
< / RangeCalendar.Grid >
< / div >
{ /each }
{ /each }
< / div >
{ /snippet }
{ /snippet }
< / RangeCalendar >
< / RangeCalendar >
< / div >
< / div >
@ -302,30 +355,30 @@
< / div >
< / div >
< div data-uix-controls >
< div data-uix-controls >
< label data-uix-control >
< label data-uix-control >
< span data-uix-control-label > minValue< / span >
< span data-uix-control-label > minValue < span data-uix-control-hint > { selectionMinValue ? . toString () ?? 'none' } </ span ></ span >
< span data-uix-chips role = "radiogroup" >
< input type = "date" bind:value = { minInput } / >
{ #each [ 'none' , 'today' , 'custom' ] as mode ( mode )}
< / label >
< button data-uix-chip data-active = { minDateMode === mode } onclick= {() => ( minDateMode = mode as MinDateMode )} > { mode } </ button >
< label data-uix-control >
{ /each }
< span data-uix-control-label > maxValue < span data-uix-control-hint > { selectionMaxValue ? . toString () ?? 'none' } </ span ></ span >
< / span >
< input type = "date" bind:value = { maxInput } / >
< span data-uix-control-label-aux > { minDateLabel } </ span >
< / label >
< / label >
< label data-uix-control >
< label data-uix-control >
< span data-uix-control-label > maxValue < / span >
< span data-uix-control-label > min/max presets < / span >
< span data-uix-chips role = "radiogroup" >
< span data-uix-chips role = "radiogroup" >
{ #each [ 'none' , 'custom' ] as mode ( mode )}
< button data-uix-chip data-active = { ! minInput && ! maxInput } onclick= {() => setBoundsPreset ( 'none' )} > none</ button >
< button data-uix-chip data-active = { maxDateMode === mode } onclick= {() => ( maxDateMode = mode as MaxDateMode )} > { mode } </ button >
< button data-uix-chip data-active = { !! minInput && ! maxInput } onclick= {() => setBoundsPreset ( 'min' )} > min only</ button >
{ /each }
< button data-uix-chip data-active = { ! minInput && !! maxInput } onclick= {() => setBoundsPreset ( 'max' )} > max only</ button >
< button data-uix-chip data-active = { minInput === toDateInput ( defaultMin ) && maxInput === toDateInput ( defaultMax )} onclick= {() => setBoundsPreset ( 'window' )} > May-Jul</ button >
< button data-uix-chip data-active = { maxInput === toDateInput ( invalidMax )} onclick= {() => setBoundsPreset ( 'invalid' )} > invalid</ button >
< / span >
< / span >
< span data-uix-control-label-aux > { maxDateLabel } </ span >
< / label >
< / label >
< label data-uix-control >
< label data-uix-control >
< span data-uix-control-label > minDays< / span >
< span data-uix-control-label > minDays< / span >
< input type = "number" min = "1" max= "60" placeholder= "—" bind:value = { minDays } style="inline-size: 4rem ;" />
< input type = "number" min = "1" placeholder= "—" bind:value = { minDays Input } style="inline-size: 4rem ;" />
< / label >
< / label >
< label data-uix-control >
< label data-uix-control >
< span data-uix-control-label > maxDays< / span >
< span data-uix-control-label > maxDays< / span >
< input type = "number" min = "1" max= "365" placeholder= "—" bind:value = { maxDays } style="inline-size: 4rem ;" />
< input type = "number" min = "1" placeholder= "—" bind:value = { maxDays Input } style="inline-size: 4rem ;" />
< / label >
< / label >
< label data-uix-control >
< label data-uix-control >
< span data-uix-control-label > allowSingleDay< / span >
< span data-uix-control-label > allowSingleDay< / span >
@ -679,3 +732,33 @@
< / section >
< / section >
{ /if }
{ /if }
< / div >
< / div >
< style >
/*
* Multi-month layout — mirrors the date-range-picker demo
* (web/routes/uix/components/date-range-picker/+page.svelte). Side-by-side
* via `grid-auto-flow: column` so the grids never stack vertically; the
* single-month case collapses to one row.
*/
.range-months {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 36px);
max-inline-size: 100%;
overflow-x: auto;
}
.range-months[data-month-count='1'] {
grid-auto-flow: row;
}
.range-month-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
< / style >