You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/date-picker/+page.svelte

1152 lines
47 KiB

<script lang="ts">
import {
DatePicker,
type DatePickerCalendarSnippetProps,
type DatePickerColor,
type DatePickerGridRowSnippetProps,
type DatePickerInputSnippetProps,
type DatePickerSize,
type DatePickerVariant
} from '$uix/eidos/components/date-picker';
import { compileMorfo } from '$uix/morfo';
import { datePickerMorfo } from '@/uix/morfo/components/date-picker';
import { calendarMorfo } from '@/uix/morfo/components/calendar';
import { dateFieldMorfo } from '@/uix/morfo/components/date-field';
import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix';
import {
CalendarDate,
type DateMatcher,
type DateValue,
type WeekStartsOn
} from '$libs/days';
const uix = getActiveUix();
void uix;
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
type BoundPreset = 'none' | 'min' | 'max' | 'window' | 'invalid';
type WeekStartControl = 'locale' | '0' | '1';
const delivery = new CalendarDate(2026, 5, 20);
const review = new CalendarDate(2026, 6, 3);
const early = new CalendarDate(2026, 5, 1);
const late = new CalendarDate(2026, 8, 1);
const defaultMin = new CalendarDate(2026, 5, 10);
const defaultMax = new CalendarDate(2026, 7, 10);
const invalidMax = new CalendarDate(2026, 5, 5);
const june = new CalendarDate(2026, 6, 1);
const july = new CalendarDate(2026, 7, 1);
const bookedDates = [new CalendarDate(2026, 5, 22), new CalendarDate(2026, 6, 12)];
const holidays = [new CalendarDate(2026, 5, 25), new CalendarDate(2026, 7, 4)];
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<DateValue | undefined>(delivery);
let valueInput = $state(toDateInput(delivery));
let placeholder = $state<DateValue>(delivery);
let open = $state(false);
let minInput = $state(toDateInput(defaultMin));
let maxInput = $state(toDateInput(defaultMax));
let required = $state(true);
let disabled = $state(false);
let readonly = $state(false);
let closeOnDateSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let kind = $state<'date' | 'month' | 'year'>('date');
// Footer composition demo toggles. Visibility is purely structural:
// the switches decide whether each part is INCLUDED in the markup,
// not whether the part itself decides to render. Omit the part →
// nothing renders. Include it → it always renders.
let showFooter = $state(true);
let showClear = $state(true);
let showCancel = $state(true);
let showClose = $state(true);
let disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(false);
let pagedNavigation = $state(false);
let preventDeselect = $state(false);
let showWeekNumbers = $state(false);
let blockWeekends = $state(false);
let markBookedUnavailable = $state(true);
let numberOfMonths = $state(1);
let weekStartsOn = $state<WeekStartControl>('1');
let locale = $state('en-US');
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<DatePickerSize>('md');
let variant = $state<DatePickerVariant>('surface');
let color = $state<DatePickerColor>('primary');
let labelText = $state('Delivery date');
let inputName = $state('delivery-date');
const compiled = compileMorfo(datePickerMorfo);
const calendarCompiled = compileMorfo(calendarMorfo);
const dateFieldCompiled = compileMorfo(dateFieldMorfo);
const popoverCompiled = compileMorfo(popoverMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const datePickerEvents = $derived([...compiled.actions.byName.values()]);
const dateFieldEvents = $derived([...dateFieldCompiled.actions.byName.values()]);
const calendarEvents = $derived([...calendarCompiled.actions.byName.values()]);
const popoverEvents = $derived([...popoverCompiled.actions.byName.values()]);
const eventRows = $derived([
...datePickerEvents.map((action) => ({ owner: 'DatePicker', action })),
...dateFieldEvents.map((action) => ({ owner: 'DateField', action })),
...calendarEvents.map((action) => ({ owner: 'Calendar', action })),
...popoverEvents.map((action) => ({ owner: 'Popover', action }))
]);
const events = $derived(eventRows.map((row) => row.action));
const minValue = $derived(parseDateInput(minInput));
const maxValue = $derived(parseDateInput(maxInput));
const resolvedWeekStartsOn = $derived(
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
);
const dateDisabledMatcher = $derived<DateMatcher | undefined>(
blockWeekends ? isWeekend : undefined
);
const dateUnavailableMatcher = $derived<DateMatcher | undefined>(
markBookedUnavailable ? isBooked : undefined
);
const boundsMessage = $derived(getBoundsMessage());
const valueMessage = $derived(getValueMessage());
const valueLabel = $derived(value?.toString() ?? 'empty');
const boundsLabel = $derived(`${minValue?.toString() ?? 'none'} / ${maxValue?.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 dateCtor(date: DateValue | undefined): string {
return date ? `new CalendarDate(${date.year}, ${date.month}, ${date.day})` : 'undefined';
}
function sameDate(a: DateValue, b: DateValue): boolean {
return a.compare(b) === 0;
}
function isWeekend(date: DateValue): boolean {
const day = date.toDate('UTC').getUTCDay();
return day === 0 || day === 6;
}
function isBooked(date: DateValue): boolean {
return bookedDates.some((item) => sameDate(item, date));
}
function isHoliday(date: DateValue): boolean {
return holidays.some((item) => sameDate(item, date));
}
function inputValue(event: Event): string {
return (event.currentTarget as HTMLInputElement).value;
}
function setValueFromInput(text: string): void {
valueInput = text;
value = parseDateInput(text);
if (value) placeholder = value;
}
function setValue(next: DateValue | undefined): void {
value = next;
valueInput = next ? toDateInput(next) : '';
if (next) placeholder = next;
}
function setVisibleMonth(next: DateValue): void {
placeholder = next;
}
function setBoundsPreset(preset: BoundPreset): void {
if (preset === 'none') {
minInput = '';
maxInput = '';
}
if (preset === 'min') {
minInput = toDateInput(defaultMin);
maxInput = '';
}
if (preset === 'max') {
minInput = '';
maxInput = toDateInput(defaultMax);
}
if (preset === 'window') {
minInput = toDateInput(defaultMin);
maxInput = toDateInput(defaultMax);
}
if (preset === 'invalid') {
minInput = toDateInput(defaultMin);
maxInput = toDateInput(invalidMax);
}
}
function getBoundsMessage(): string {
if (minInput && !minValue) return 'minValue is not a valid date.';
if (maxInput && !maxValue) return 'maxValue is not a valid date.';
if (minValue && maxValue && maxValue.compare(minValue) < 0) {
return 'minValue must be on or before maxValue.';
}
return '';
}
function getValueMessage(): string {
if (!value) return required ? 'Required value is empty.' : 'No selected date.';
if (minValue && value.compare(minValue) < 0) return 'Selected date is before minValue.';
if (maxValue && value.compare(maxValue) > 0) return 'Selected date is after maxValue.';
if (dateDisabledMatcher?.(value)) return 'Selected date matches isDateDisabled.';
if (dateUnavailableMatcher?.(value)) return 'Selected date matches isDateUnavailable.';
return 'Selected date is valid.';
}
function monthHeadingId(date: DateValue): string {
return `date-picker-month-${date.toString()}`;
}
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$effect(() => {
valueInput = value ? toDateInput(value) : '';
});
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as DatePicker from '$soma/components/date-picker';",
" import { CalendarDate, type DateValue } from '$libs/days';",
'',
` const minValue = ${dateCtor(minValue)};`,
` const maxValue = ${dateCtor(maxValue)};`,
` let value = $state<DateValue | undefined>(${dateCtor(value)});`,
` let placeholder = $state<DateValue>(${dateCtor(placeholder)});`,
' let open = $state(false);',
' const monthHeadingId = (date: DateValue) => `date-picker-month-${date.toString()}`;',
'</' + 'script>',
'',
'<DatePicker.Provider',
' bind:value',
' bind:placeholder',
' bind:open',
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
minValue && ' {minValue}',
maxValue && ' {maxValue}',
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
numberOfMonths !== 1 && ` numberOfMonths={${numberOfMonths}}`,
disableDaysOutsideMonth && ' disableDaysOutsideMonth',
showWeekNumbers && ' showWeekNumbers',
weekStartsOn !== 'locale' && ` weekStartsOn={${weekStartsOn}}`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
blockWeekends && ' isDateDisabled={(date) => date.toDate("UTC").getUTCDay() % 6 === 0}',
markBookedUnavailable && ' isDateUnavailable={(date) => date.day === 22}',
' weekdayFormat="short"',
'>',
` <DatePicker.Label>${labelText}</DatePicker.Label>`,
` <DatePicker.Input name="${inputName}" aria-label="${labelText}">`,
' {#snippet children({ segments })}',
' {#each segments as { part, value }, index (`${part}-${index}`)}',
' <DatePicker.Segment {part}>{value}</DatePicker.Segment>',
' {/each}',
' <DatePicker.Trigger />',
' {/snippet}',
' </DatePicker.Input>',
' <DatePicker.Portal>',
' <DatePicker.Content align="end" sideOffset={10} collisionPadding={12}>',
' <DatePicker.Calendar>',
' {#snippet children({ months, weekdays })}',
' <DatePicker.Header>...</DatePicker.Header>',
' {#each months as month}',
' {#if months.length > 1}',
' <DatePicker.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <DatePicker.Grid>...</DatePicker.Grid>',
' {/each}',
' {/snippet}',
' </DatePicker.Calendar>',
' </DatePicker.Content>',
' </DatePicker.Portal>',
'</DatePicker.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { DatePicker } from '$uix/eidos/components/date-picker';",
" import { CalendarDate, type DateValue } from '$libs/days';",
'',
` const minValue = ${dateCtor(minValue)};`,
` const maxValue = ${dateCtor(maxValue)};`,
` let value = $state<DateValue | undefined>(${dateCtor(value)});`,
` let placeholder = $state<DateValue>(${dateCtor(placeholder)});`,
' let open = $state(false);',
' const monthHeadingId = (date: DateValue) => `date-picker-month-${date.toString()}`;',
'</' + 'script>',
'',
'<DatePicker',
' bind:value',
' bind:placeholder',
' bind:open',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
minValue && ' {minValue}',
maxValue && ' {maxValue}',
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
numberOfMonths !== 1 && ` numberOfMonths={${numberOfMonths}}`,
disableDaysOutsideMonth && ' disableDaysOutsideMonth',
showWeekNumbers && ' showWeekNumbers',
weekStartsOn !== 'locale' && ` weekStartsOn={${weekStartsOn}}`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
blockWeekends && ' isDateDisabled={(date) => date.toDate("UTC").getUTCDay() % 6 === 0}',
markBookedUnavailable && ' isDateUnavailable={(date) => date.day === 22}',
' weekdayFormat="short"',
'>',
` <DatePicker.Label>${labelText}</DatePicker.Label>`,
` <DatePicker.Input name="${inputName}" aria-label="${labelText}">`,
' {#snippet children({ segments })}',
' {#each segments as { part, value }, index (`${part}-${index}`)}',
' <DatePicker.Segment {part}>{value}</DatePicker.Segment>',
' {/each}',
' <DatePicker.Trigger />',
' {/snippet}',
' </DatePicker.Input>',
' <DatePicker.Portal>',
' <DatePicker.Content align="end" sideOffset={10} collisionPadding={12}>',
' <DatePicker.Calendar>',
' {#snippet children({ months, weekdays })}',
' <DatePicker.Header>...</DatePicker.Header>',
' {#each months as month}',
' {#if months.length > 1}',
' <DatePicker.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <DatePicker.Grid>...</DatePicker.Grid>',
' {/each}',
' {/snippet}',
' </DatePicker.Calendar>',
' </DatePicker.Content>',
' </DatePicker.Portal>',
'</DatePicker>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · DatePicker</div>
<h1 data-uix-page-title>DatePicker</h1>
<p data-uix-page-lede>
Date input with an attached calendar popover. DatePicker owns the shared value, visible
month and open state; Soma composes DateField, Popover and Calendar, while Eidos only adds
the visual size, variant and color recipe.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span>{valueLabel}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>min/max</span>{boundsLabel}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>months</span>{numberOfMonths}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div class="date-picker-stage">
<DatePicker
bind:value
bind:placeholder
bind:open
{required}
{disabled}
{readonly}
{closeOnDateSelect}
{disableDaysOutsideMonth}
{fixedWeeks}
{pagedNavigation}
{preventDeselect}
{showWeekNumbers}
{numberOfMonths}
{locale}
{dir}
{size}
{variant}
{color}
{mode}
{kind}
minValue={minValue}
maxValue={maxValue}
weekStartsOn={resolvedWeekStartsOn}
isDateDisabled={dateDisabledMatcher}
isDateUnavailable={dateUnavailableMatcher}
isDateHoliday={isHoliday}
weekdayFormat="short"
>
<DatePicker.Label>{labelText}</DatePicker.Label>
<DatePicker.Input name={inputName} aria-label={labelText}>
{#snippet children({ segments }: DatePickerInputSnippetProps)}
{#each segments as { part, value }, index (`${part}-${index}`)}
<DatePicker.Segment {part}>{value}</DatePicker.Segment>
{/each}
<DatePicker.Trigger />
{/snippet}
</DatePicker.Input>
<div class="date-picker-status" data-invalid={boundsMessage || valueMessage !== 'Selected date is valid.' || undefined}>
<span>{boundsMessage || valueMessage}</span>
</div>
<DatePicker.Portal>
<DatePicker.Content align="end" sideOffset={10} collisionPadding={12}>
{#if kind === 'year'}
<DatePicker.YearView />
{:else if kind === 'month'}
<DatePicker.MonthView />
{:else}
<DatePicker.Calendar>
{#snippet children(args: DatePickerCalendarSnippetProps)}
{@render calendarBody(args)}
{/snippet}
</DatePicker.Calendar>
{/if}
{@render pickerFooter()}
</DatePicker.Content>
</DatePicker.Portal>
</DatePicker>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>open the picker and select a date</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>open</span> {String(open)}
<span data-uix-stage-trace-key style="margin-inline-start: var(--uix-space-2);">value</span>
{valueLabel}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>17</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Props grouped by the layer that owns them:
<span data-uix-layer-badge="soma">soma</span> keeps value, min/max, popover
state and calendar behavior; <span data-uix-layer-badge="eidos">eidos</span>
adds visual treatment. Sema events live in the dedicated
<a href="#sema" onclick={(event) => { event.preventDefault(); tab = 'sema'; }}>Sema</a>
tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value, min/max and calendar
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>open <span data-uix-control-hint>bindable</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={open} />
<span data-uix-switch-label>{open ? 'open' : 'closed'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>selected date</span></span>
<input type="date" value={valueInput} oninput={(event) => setValueFromInput(inputValue(event))} />
<span data-uix-chips role="group">
<button data-uix-chip data-active={sameDate(value ?? delivery, delivery)} onclick={() => setValue(delivery)}>May 20</button>
<button data-uix-chip data-active={sameDate(value ?? delivery, review)} onclick={() => setValue(review)}>Jun 3</button>
<button data-uix-chip data-active={value === undefined} onclick={() => setValue(undefined)}>empty</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>minValue <span data-uix-control-hint>{minValue?.toString() ?? 'none'}</span></span>
<input type="date" bind:value={minInput} />
</label>
<label data-uix-control>
<span data-uix-control-label>maxValue <span data-uix-control-hint>{maxValue?.toString() ?? 'none'}</span></span>
<input type="date" bind:value={maxInput} />
</label>
<label data-uix-control>
<span data-uix-control-label>min/max preset</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={!minValue && !maxValue} onclick={() => setBoundsPreset('none')}>none</button>
<button data-uix-chip data-active={!!minValue && !maxValue} onclick={() => setBoundsPreset('min')}>min only</button>
<button data-uix-chip data-active={!minValue && !!maxValue} onclick={() => setBoundsPreset('max')}>max only</button>
<button data-uix-chip data-active={minValue?.compare(defaultMin) === 0 && maxValue?.compare(defaultMax) === 0} onclick={() => setBoundsPreset('window')}>May-Jul</button>
<button data-uix-chip data-active={!!boundsMessage} onclick={() => setBoundsPreset('invalid')}>invalid</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>visible month</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={placeholder.month === 5} onclick={() => setVisibleMonth(delivery)}>May</button>
<button data-uix-chip data-active={placeholder.month === 6} onclick={() => setVisibleMonth(june)}>Jun</button>
<button data-uix-chip data-active={placeholder.month === 7} onclick={() => setVisibleMonth(july)}>Jul</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>numberOfMonths</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2] as value}
<button data-uix-chip data-active={numberOfMonths === value} onclick={() => (numberOfMonths = value)}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>weekStartsOn</span>
<span data-uix-chips role="radiogroup">
{#each ['locale', '0', '1'] as value}
<button data-uix-chip data-active={weekStartsOn === value} onclick={() => (weekStartsOn = value as WeekStartControl)}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="fr-FR">fr-FR</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as value}
<button data-uix-chip data-active={dir === value} onclick={() => (dir = value as 'ltr' | 'rtl')}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>state flags</span>
<span data-uix-switch><input type="checkbox" bind:checked={required} /><span data-uix-switch-label>required</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>disabled</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={readonly} /><span data-uix-switch-label>readonly</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
<label data-uix-chip data-active={mode === 'inline'}>
<input type="radio" bind:group={mode} value="inline" />
inline
</label>
<label data-uix-chip data-active={mode === 'modal'}>
<input type="radio" bind:group={mode} value="modal" />
modal
</label>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>kind <span data-uix-control-hint>{kind === 'year' ? 'YYYY' : kind === 'month' ? 'MM/YYYY' : 'MM/DD/YYYY'}</span></span
>
<span data-uix-chips role="radiogroup">
{#each ['date', 'month', 'year'] as value (value)}
<button
data-uix-chip
data-active={kind === value}
onclick={() => (kind = value as 'date' | 'month' | 'year')}
>
{value}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>footer <span data-uix-control-hint>compose parts inside &lt;Footer&gt;</span></span
>
<span data-uix-switch
><input type="checkbox" bind:checked={showFooter} /><span data-uix-switch-label
>Footer</span
></span
>
<span data-uix-switch
><input type="checkbox" bind:checked={showClear} /><span data-uix-switch-label
>Clear</span
></span
>
<span data-uix-switch
><input type="checkbox" bind:checked={showCancel} /><span data-uix-switch-label
>Cancel</span
></span
>
<span data-uix-switch
><input type="checkbox" bind:checked={showClose} /><span data-uix-switch-label
>Close</span
></span
>
</label>
<label data-uix-control>
<span data-uix-control-label>calendar behavior</span>
<span data-uix-switch><input type="checkbox" bind:checked={closeOnDateSelect} /><span data-uix-switch-label>close on select</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={disableDaysOutsideMonth} /><span data-uix-switch-label>disable outside</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={fixedWeeks} /><span data-uix-switch-label>fixed weeks</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={pagedNavigation} /><span data-uix-switch-label>paged nav</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={preventDeselect} /><span data-uix-switch-label>prevent deselect</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={showWeekNumbers} /><span data-uix-switch-label>week numbers</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>date matchers</span>
<span data-uix-switch><input type="checkbox" bind:checked={blockWeekends} /><span data-uix-switch-label>disable weekends</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={markBookedUnavailable} /><span data-uix-switch-label>booked unavailable</span></span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg'] as value}
<button data-uix-chip data-active={size === value} onclick={() => (size = value as DatePickerSize)}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as value}
<button data-uix-chip data-active={variant === value} onclick={() => (variant = value as DatePickerVariant)}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'risk'] as value}
<button data-uix-chip data-active={color === value} onclick={() => (color = value as DatePickerColor)}>{value}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Demo content</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>label</span>
<input type="text" bind:value={labelText} />
</label>
<label data-uix-control>
<span data-uix-control-label>input name</span>
<input type="text" bind:value={inputName} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · shared value, min/max, popover and calendar</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root plus attached DateField, Popover and Calendar parts</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Public DatePicker surface used by this demo. The component is date-first: value,
min/max, popup state and calendar behavior are the relevant controls here.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">undefined</td><td>Selected `DateValue`. Calendar and input segments share this state.</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">today</td><td>Visible month anchor when the picker opens.</td></tr>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">false</td><td>Popover open state.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default empty">-</td><td>Selectable date limits. Invalid min/max marks Calendar invalid and exposes the message.</td></tr>
<tr><td class="name">isDateDisabled</td><td>soma</td><td class="default empty">-</td><td>Matcher for hard-disabled days.</td></tr>
<tr><td class="name">isDateUnavailable</td><td>soma</td><td class="default empty">-</td><td>Matcher for unavailable days that remain visible in the grid.</td></tr>
<tr><td class="name">required / disabled / readonly</td><td>soma</td><td class="default">false</td><td>Root, input, trigger and calendar state.</td></tr>
<tr><td class="name">closeOnDateSelect</td><td>soma</td><td class="default">true</td><td>Close the popover after a calendar selection.</td></tr>
<tr><td class="name">numberOfMonths</td><td>soma</td><td class="default">1</td><td>Render one or two visible months in the popup.</td></tr>
<tr><td class="name">fixedWeeks / pagedNavigation</td><td>soma</td><td class="default">false</td><td>Calendar grid height and navigation step.</td></tr>
<tr><td class="name">disableDaysOutsideMonth</td><td>soma</td><td class="default">true</td><td>Days from adjacent months are shown but not selectable.</td></tr>
<tr><td class="name">weekStartsOn / locale / dir</td><td>soma</td><td class="default">services</td><td>Calendar ordering, labels and directional keys.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">md</td><td>Shared visual scale for field, trigger and calendar.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">surface</td><td>Visual treatment of the input and popup calendar.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">primary</td><td>Intent palette for focus and selected-day affordances.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>Air</th><th>React Aria</th><th>Ark</th><th>Bits</th><th>UIX</th><th>Decision</th></tr></thead>
<tbody>
<tr><td class="name">Segmented input + calendar popover</td><td>no local Air component</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Compose DateField, Popover and Calendar.</td></tr>
<tr><td class="name">Explicit minValue / maxValue</td><td>-</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Expose directly in Soma and demo controls.</td></tr>
<tr><td class="name">Unavailable / disabled matchers</td><td>-</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Forward to Calendar as first-class picker props.</td></tr>
<tr><td class="name">Multiple months</td><td>-</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Calendar remains the source of month layout.</td></tr>
<tr><td class="name">Composite semantic events</td><td>-</td><td>callbacks</td><td>callbacks</td><td>callbacks</td><td>yes</td><td>Field, calendar and popover events are surfaced together in the picker demo.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/date-picker.ts</code>. DatePicker declares only
the composition-specific root, trigger and calendar bridge. DateField, Popover and
Calendar keep their own contracts.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{datePickerMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{datePickerMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{datePickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td class="default">composed: DateField + Popover + Calendar</td></tr>
<tr><td class="name">parts.length</td><td class="default">{datePickerMorfo.parts.length}</td></tr>
<tr><td class="name">own events</td><td class="default">{datePickerEvents.length}</td></tr>
<tr><td class="name">composed events</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '-'}</td>
<td class="default">{part.archetype ?? '-'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '-'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each datePickerMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>; keyboard?: ReadonlyArray<{ key: string; action: string }> }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{@const keyboard = partAny.keyboard ?? []}
{#if dataAttrs.length || ariaAttrs.length || keyboard.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '-'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if keyboard.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>key</th><th>action</th></tr></thead>
<tbody>
{#each keyboard as kb}
<tr>
<td><span data-uix-kbd>{kb.key === ' ' ? 'Space' : kb.key}</span></td>
<td class="default">{kb.action}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target</th></tr></thead>
<tbody>
{#each eventRows as { owner, action }}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
<tr>
<td class="name">{owner}.{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}</td>
<td class="type">{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
The picker event surface is composed: DateField commits typed segment changes,
Calendar commits date selection and navigation, and Popover owns disclosure events.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Owner</th><th>Events</th><th>Why</th></tr></thead>
<tbody>
<tr><td class="name">DatePicker</td><td class="default">{datePickerEvents.length}</td><td>Root state and composition.</td></tr>
<tr><td class="name">DateField</td><td class="default">{dateFieldEvents.length}</td><td>Typed date commits and clears.</td></tr>
<tr><td class="name">Calendar</td><td class="default">{calendarEvents.length}</td><td>Date select, unselect and month navigation.</td></tr>
<tr><td class="name">Popover</td><td class="default">{popoverEvents.length}</td><td>Open and close causes.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Composed event declarations</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Owner</th><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th></tr></thead>
<tbody>
{#each eventRows as { owner, action }}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
<tr>
<td class="name">{owner}</td>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors live in <code>src/uix/eidos/components/date-picker/date-picker.css</code>.
The root supplies DateField variables and the popup calendar reuses the Calendar recipe
without drawing a second card.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-date-picker]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Root provider, open and validation states.</td></tr>
<tr><td class="name"><code>[data-date-picker][data-size][data-variant][data-color]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Visual scale, treatment and intent palette.</td></tr>
<tr><td class="name"><code>[data-date-picker-trigger]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Calendar opener button.</td></tr>
<tr><td class="name"><code>[data-popover-content]:has([data-date-picker-calendar])</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Popup width follows one or two calendar months.</td></tr>
<tr><td class="name"><code>[data-date-picker-calendar][data-calendar]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Calendar bridge with nested card chrome removed.</td></tr>
<tr><td class="name"><code>[data-calendar-day][data-selected]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Selected date state inherited from Calendar.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The picker combines the ARIA contracts of DateField, Popover and Calendar. The trigger
opens a dialog-style popup; the grid keeps Calendar keyboard behavior.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Open the popover from Trigger; select focused day inside Calendar.</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>Close the popover.</td></tr>
<tr><td><span data-uix-kbd>Arrow keys</span></td><td>Edit date segments in the input or move focus by day/week in the calendar grid.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>Calendar month navigation.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>Move to first or last day of the current week.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">root</td><td>aria-disabled / aria-readonly / aria-required</td><td class="type">{`{state flags}`}</td></tr>
<tr><td class="name">input</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">segment</td><td>role</td><td class="type">"spinbutton"</td></tr>
<tr><td class="name">trigger</td><td>aria-haspopup</td><td class="type">"dialog"</td></tr>
<tr><td class="name">trigger</td><td>aria-expanded</td><td class="type">{`{open}`}</td></tr>
<tr><td class="name">content</td><td>role</td><td class="type">"dialog"</td></tr>
<tr><td class="name">calendar grid</td><td>role</td><td class="type">"grid"</td></tr>
<tr><td class="name">calendar day</td><td>aria-selected / aria-disabled</td><td class="type">{`{date state}`}</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
{#snippet pickerFooter()}
{#if showFooter && (showClear || showCancel || showClose)}
<DatePicker.Footer>
{#if showClear}<DatePicker.Clear />{/if}
{#if showCancel}<DatePicker.Cancel />{/if}
{#if showClose}<DatePicker.Close />{/if}
</DatePicker.Footer>
{/if}
{/snippet}
{#snippet calendarBody({ months, weekdays, validationMessage }: DatePickerCalendarSnippetProps)}
<DatePicker.Header>
<DatePicker.PrevButton aria-label="Previous month">&lt;</DatePicker.PrevButton>
<div data-calendar-header-controls>
<DatePicker.MonthSelect />
<DatePicker.YearSelect />
</div>
<DatePicker.NextButton aria-label="Next month">&gt;</DatePicker.NextButton>
</DatePicker.Header>
{#if validationMessage}
<p class="calendar-error" role="alert">{validationMessage}</p>
{/if}
<div class="month-stack" data-month-count={months.length}>
{#each months as month (month.value.toString())}
<div data-calendar-month-panel>
{#if months.length > 1}
<DatePicker.Heading id={monthHeadingId(month.value)} value={month.value} />
{/if}
<DatePicker.Grid labelledBy={months.length > 1 ? monthHeadingId(month.value) : undefined}>
<DatePicker.GridHead>
<DatePicker.GridRow>
{#if showWeekNumbers}
<th data-calendar-head-cell data-calendar-week-number scope="col">wk</th>
{/if}
{#each weekdays as day}
<DatePicker.HeadCell>{day}</DatePicker.HeadCell>
{/each}
</DatePicker.GridRow>
</DatePicker.GridHead>
<DatePicker.GridBody>
{#each month.weeks as week}
<DatePicker.GridRow {week}>
{#snippet children({ weekNumber }: DatePickerGridRowSnippetProps)}
{#if showWeekNumbers}
<th data-calendar-head-cell data-calendar-week-number scope="row">
{weekNumber ?? ''}
</th>
{/if}
{#each week as date}
<DatePicker.Cell {date} month={month.value}>
<DatePicker.Day />
</DatePicker.Cell>
{/each}
{/snippet}
</DatePicker.GridRow>
{/each}
</DatePicker.GridBody>
</DatePicker.Grid>
</div>
{/each}
</div>
{/snippet}
<style>
.date-picker-stage {
inline-size: min(32rem, 100%);
}
.date-picker-status {
display: flex;
align-items: center;
min-block-size: 1.5rem;
color: var(--uix-text-muted);
font-size: var(--uix-text-sm);
}
.date-picker-status[data-invalid='true'] {
color: var(--color-threat-text);
}
.month-stack {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 32px);
max-inline-size: 100%;
overflow-x: auto;
}
.month-stack[data-month-count='1'] {
grid-auto-flow: row;
}
.calendar-error {
margin: 0;
max-inline-size: calc((var(--_calendar-day-size) * 7) + (var(--calendar-grid-gap) * 8));
color: var(--color-threat-text);
font-size: var(--uix-text-sm);
text-align: center;
overflow-wrap: anywhere;
}
[data-uix-control] input[type='date'] {
min-inline-size: 0;
padding: 6px var(--uix-space-3);
border: 1px solid var(--uix-line);
border-radius: var(--uix-radius-sm);
background: var(--uix-surface);
color: var(--uix-text);
font: inherit;
font-size: var(--uix-text-sm);
}
[data-uix-control] input[type='date']:focus {
outline: none;
border-color: var(--uix-accent-line);
box-shadow: var(--uix-ring);
}
</style>

Powered by TurnKey Linux.