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.
1390 lines
58 KiB
1390 lines
58 KiB
<script lang="ts">
|
|
import {
|
|
DatePicker,
|
|
type DatePickerCalendarSnippetProps,
|
|
type DatePickerColor,
|
|
type DatePickerGridRowSnippetProps,
|
|
type DatePickerInputSnippetProps,
|
|
type DatePickerSize,
|
|
type DatePickerVariant
|
|
} from '$uix/eidos/components/date-picker';
|
|
// The embedded <TimePicker> recipe + its TimeField/Slider dependencies.
|
|
// Imported directly so the route loads them regardless of the component
|
|
// import graph.
|
|
import '$uix/eidos/components/time-field/time-field.css';
|
|
import '$uix/eidos/components/slider/slider.css';
|
|
import '$uix/eidos/components/time-picker/time-picker.css';
|
|
import {
|
|
TimePicker,
|
|
type InputSnippetProps as TimePickerInputSnippetProps
|
|
} from '$uix/eidos/components/time-picker';
|
|
import { PickerShell } from '$uix/eidos/components/picker-shell';
|
|
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,
|
|
CalendarDateTime,
|
|
Time,
|
|
toCalendarDate,
|
|
toCalendarDateTime,
|
|
toTime,
|
|
type DateMatcher,
|
|
type DateValue,
|
|
type TimeValue,
|
|
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<'day' | 'month' | 'year'>('day');
|
|
let withTime = $state(false);
|
|
// Finest time channel shown when datetime is on. 'hour' → only the hour
|
|
// slider; 'minute' → + minute; 'second' → + second.
|
|
let timeGranularity = $state<'hour' | 'minute' | 'second'>('minute');
|
|
let timeOpen = $state(false);
|
|
|
|
// Datetime is meaningful only for the day grid. The DateField input itself
|
|
// INCORPORATES the granularity (renders HH:MM time segments); the composed
|
|
// <TimePicker> below the calendar is a second, visual way to set the same time.
|
|
const granularity = $derived<'hour' | 'minute' | 'second' | undefined>(
|
|
withTime && kind === 'day' ? timeGranularity : undefined
|
|
);
|
|
|
|
// Bridge the time-of-day portion of the picker value to the composed
|
|
// TimePicker (eidos already ships it): read `value` → Time, write Time back
|
|
// onto the date. The calendar preserves time on date-select, so the two
|
|
// halves stay coherent.
|
|
const timeValue = $derived<TimeValue | undefined>(
|
|
value instanceof CalendarDateTime ? toTime(value) : undefined
|
|
);
|
|
function setTimeValue(next: TimeValue | undefined): void {
|
|
if (!next) {
|
|
if (value instanceof CalendarDateTime) value = toCalendarDate(value);
|
|
return;
|
|
}
|
|
value = toCalendarDateTime(value ?? placeholder, next);
|
|
}
|
|
|
|
// Toggle time on/off, coercing the bound value + placeholder between
|
|
// CalendarDate and CalendarDateTime so the input + TimeField stay coherent.
|
|
function setWithTime(next: boolean): void {
|
|
withTime = next;
|
|
const seed = new Time(9, 30, 0);
|
|
if (next) {
|
|
if (value && !(value instanceof CalendarDateTime)) value = toCalendarDateTime(value, seed);
|
|
if (!(placeholder instanceof CalendarDateTime)) placeholder = toCalendarDateTime(placeholder, seed);
|
|
} else {
|
|
if (value instanceof CalendarDateTime) value = toCalendarDate(value);
|
|
if (placeholder instanceof CalendarDateTime) placeholder = toCalendarDate(placeholder);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
// PickerShell.Header toggle — demonstrates the optional top zone of
|
|
// the canonical 4-part picker layout. The Header sits above Body and
|
|
// renders with its own bottom border, mirroring the Footer.
|
|
let showShellHeader = $state(false);
|
|
|
|
let disableDaysOutsideMonth = $state(true);
|
|
// On by default so the popover keeps a constant 6-row height (the popup no
|
|
// longer grows between 5- and 6-week months).
|
|
let fixedWeeks = $state(true);
|
|
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');
|
|
// Derive the hour cycle from the chosen locale and pass it as an explicit
|
|
// property to the picker + the composed TimePicker. The field providers
|
|
// otherwise resolve the cycle from the app-wide `getHourCycle()` preference
|
|
// (which wins over a field's `locale` prop), so a locale change alone would
|
|
// not flip 24h ⇄ 12h. en-US → 12h (AM/PM), es-ES / de → 24h.
|
|
const localeHourCycle = $derived<12 | 24>(
|
|
new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ? 12 : 24
|
|
);
|
|
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 !== 'day' && ` kind="${kind}"`,
|
|
granularity && ` granularity="${granularity}"`,
|
|
granularity && ' hourCycle={localeHourCycle}',
|
|
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}>',
|
|
showShellHeader && ' <PickerShell.Header>Pick a date</PickerShell.Header>',
|
|
' <PickerShell.Body>',
|
|
' <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>',
|
|
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
|
|
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={localeHourCycle} {granularity}>',
|
|
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
|
|
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
|
|
granularity && ' <TimePicker.HourSlider />',
|
|
granularity && ' <TimePicker.MinuteSlider /> <!-- self-hides at granularity=hour -->',
|
|
granularity && ' <TimePicker.SecondSlider /> <!-- self-hides unless granularity=second -->',
|
|
granularity && ' <TimePicker.DayPeriodToggle /> <!-- self-hides at 24h -->',
|
|
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
|
|
granularity && ' </TimePicker>',
|
|
' </PickerShell.Body>',
|
|
showFooter && (showClear || showCancel || showClose) && ' <DatePicker.Footer>',
|
|
showFooter && showClear && ' <DatePicker.Clear />',
|
|
showFooter && showCancel && ' <DatePicker.Cancel />',
|
|
showFooter && showClose && ' <DatePicker.Close />',
|
|
showFooter && (showClear || showCancel || showClose) && ' </DatePicker.Footer>',
|
|
' </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 { PickerShell } from '$uix/eidos/components/picker-shell';",
|
|
" 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 !== 'day' && ` kind="${kind}"`,
|
|
granularity && ` granularity="${granularity}"`,
|
|
granularity && ' hourCycle={localeHourCycle}',
|
|
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}>',
|
|
showShellHeader && ' <PickerShell.Header>Pick a date</PickerShell.Header>',
|
|
' <PickerShell.Body>',
|
|
' <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>',
|
|
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
|
|
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={localeHourCycle} {granularity}>',
|
|
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
|
|
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
|
|
granularity && ' <TimePicker.HourSlider />',
|
|
granularity && ' <TimePicker.MinuteSlider /> <!-- self-hides at granularity=hour -->',
|
|
granularity && ' <TimePicker.SecondSlider /> <!-- self-hides unless granularity=second -->',
|
|
granularity && ' <TimePicker.DayPeriodToggle /> <!-- self-hides at 24h -->',
|
|
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
|
|
granularity && ' </TimePicker>',
|
|
' </PickerShell.Body>',
|
|
showFooter && (showClear || showCancel || showClose) && ' <DatePicker.Footer>',
|
|
showFooter && showClear && ' <DatePicker.Clear />',
|
|
showFooter && showCancel && ' <DatePicker.Cancel />',
|
|
showFooter && showClose && ' <DatePicker.Close />',
|
|
showFooter && (showClear || showCancel || showClose) && ' </DatePicker.Footer>',
|
|
' </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}
|
|
{granularity}
|
|
hourCycle={localeHourCycle}
|
|
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 showShellHeader}
|
|
<PickerShell.Header>Pick a date</PickerShell.Header>
|
|
{/if}
|
|
<PickerShell.Body>
|
|
{#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 granularity}
|
|
<div class="date-picker-time-row">
|
|
<span class="date-picker-time-label">Time</span>
|
|
<TimePicker
|
|
value={timeValue}
|
|
onValueChange={setTimeValue}
|
|
bind:open={timeOpen}
|
|
{size}
|
|
{variant}
|
|
{color}
|
|
{locale}
|
|
hourCycle={localeHourCycle}
|
|
{granularity}
|
|
>
|
|
<TimePicker.Input aria-label="Time">
|
|
{#snippet children({ segments }: TimePickerInputSnippetProps)}
|
|
{#each segments as { part, value }, i (`${part}-${i}`)}
|
|
<TimePicker.Segment {part}>{value}</TimePicker.Segment>
|
|
{/each}
|
|
<TimePicker.Trigger />
|
|
{/snippet}
|
|
</TimePicker.Input>
|
|
<TimePicker.Portal>
|
|
<TimePicker.Content>
|
|
<PickerShell.Body>
|
|
<!-- Compose all channels; the TimePicker self-hides
|
|
the sliders / AM-PM toggle that the resolved
|
|
granularity + hour cycle don't apply. -->
|
|
<TimePicker.Clock>
|
|
<TimePicker.HourSlider />
|
|
<TimePicker.MinuteSlider />
|
|
<TimePicker.SecondSlider />
|
|
<TimePicker.DayPeriodToggle />
|
|
</TimePicker.Clock>
|
|
</PickerShell.Body>
|
|
</TimePicker.Content>
|
|
</TimePicker.Portal>
|
|
</TimePicker>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</PickerShell.Body>
|
|
{@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 ['day', 'month', 'year'] as value (value)}
|
|
<button
|
|
data-uix-chip
|
|
data-active={kind === value}
|
|
onclick={() => (kind = value as 'day' | 'month' | 'year')}
|
|
>
|
|
{value}
|
|
</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>time <span data-uix-control-hint>datetime · TimeField below the calendar</span></span
|
|
>
|
|
<span data-uix-switch
|
|
><input
|
|
type="checkbox"
|
|
checked={withTime}
|
|
disabled={kind !== 'day'}
|
|
onchange={(e) => setWithTime(e.currentTarget.checked)}
|
|
/><span data-uix-switch-label>include time</span></span
|
|
>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>granularity <span data-uix-control-hint>finest time channel + slider</span></span
|
|
>
|
|
<span data-uix-chips role="radiogroup">
|
|
{#each ['hour', 'minute', 'second'] as value (value)}
|
|
<button
|
|
data-uix-chip
|
|
data-active={timeGranularity === value}
|
|
disabled={!withTime || kind !== 'day'}
|
|
onclick={() => (timeGranularity = value as 'hour' | 'minute' | 'second')}
|
|
>
|
|
{value}
|
|
</button>
|
|
{/each}
|
|
</span>
|
|
</label>
|
|
<label data-uix-control>
|
|
<span data-uix-control-label
|
|
>footer <span data-uix-control-hint>compose parts inside <Footer></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
|
|
>shell header <span data-uix-control-hint
|
|
>optional <PickerShell.Header> zone</span
|
|
></span
|
|
>
|
|
<span data-uix-switch
|
|
><input type="checkbox" bind:checked={showShellHeader} /><span
|
|
data-uix-switch-label>Header</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"><{part.defaultElement}></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" />
|
|
<div data-calendar-header-controls>
|
|
<DatePicker.MonthSelect />
|
|
<DatePicker.YearSelect />
|
|
</div>
|
|
<DatePicker.NextButton aria-label="Next month" />
|
|
</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);
|
|
}
|
|
|
|
/* datetime: composed <TimePicker> below the calendar. Uses global eidos
|
|
* tokens (the docs --uix-* tokens don't resolve inside the portal). The
|
|
* top border separates it from the calendar; the footer's own top border
|
|
* separates it from the footer below. */
|
|
.date-picker-time-row {
|
|
display: flex;
|
|
align-items: center;
|
|
/* Centered so the TimePicker isn't shoved against the popover edge (was
|
|
space-between). Full-width row → the divider spans it; content centered. */
|
|
justify-content: center;
|
|
gap: var(--space-3);
|
|
padding-block-start: var(--space-2);
|
|
padding-inline: var(--space-2);
|
|
/* `--color-border-subtle` / `--calendar-border` resolve to the SAME oklch as
|
|
the popover surface here (invisible). Use the neutral border so the
|
|
divider actually shows. */
|
|
border-block-start: var(--border-width, 1px) solid var(--color-neutral-border);
|
|
}
|
|
|
|
.date-picker-time-label {
|
|
flex: 0 0 auto;
|
|
color: var(--color-content-secondary);
|
|
/* Scales with the picker — date-picker.css now sets --_calendar-font-size on
|
|
the popover content per data-picker-size, so this simple (scoped) rule
|
|
inherits the right size. The previous [data-popover-content] demo selector
|
|
couldn't scope to the portaled popover. */
|
|
font-size: var(--_calendar-font-size);
|
|
}
|
|
|
|
/* Content-sized, no shrink: keeps the segmented input from collapsing and
|
|
* overflowing to the right. */
|
|
.date-picker-time-row :global([data-time-picker]) {
|
|
inline-size: auto;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.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>
|