|
|
<script lang="ts">
|
|
|
import {
|
|
|
RangeCalendar,
|
|
|
type RangeCalendarColor,
|
|
|
type RangeCalendarGridRowSnippetProps,
|
|
|
type RangeCalendarProviderSnippetProps,
|
|
|
type RangeCalendarSize,
|
|
|
type RangeCalendarVariant
|
|
|
} from '$uix/eidos/components/range-calendar';
|
|
|
import { compileMorfo } from '$uix/morfo';
|
|
|
import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar';
|
|
|
import { getActiveUix } from '$active-uix';
|
|
|
import { CalendarDate, type DateRange, type DateValue, type WeekStartsOn } from '$libs/days';
|
|
|
|
|
|
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
|
|
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
|
|
type WeekStartControl = 'locale' | '0' | '1';
|
|
|
|
|
|
const uix = getActiveUix();
|
|
|
|
|
|
// Fixed anchor dates so the demo is deterministic across reloads. Same
|
|
|
// pattern as the date-range-picker demo (which this view mirrors), with
|
|
|
// presets letting consumers see the picker's behaviour under common
|
|
|
// real-world configurations.
|
|
|
const initialMonth = new CalendarDate(2026, 5, 1);
|
|
|
const sprintStart = new CalendarDate(2026, 5, 8);
|
|
|
const sprintEnd = new CalendarDate(2026, 5, 19);
|
|
|
const defaultMin = new CalendarDate(2026, 5, 1);
|
|
|
const defaultMax = new CalendarDate(2026, 7, 31);
|
|
|
const invalidMax = new CalendarDate(2026, 4, 30);
|
|
|
|
|
|
let tab = $state<Tab>('live');
|
|
|
let trace = $state<TraceEntry[]>([]);
|
|
|
let stageRef = $state<HTMLElement | null>(null);
|
|
|
|
|
|
let value = $state<DateRange>({ start: sprintStart, end: sprintEnd });
|
|
|
let placeholder = $state<DateValue>(initialMonth);
|
|
|
|
|
|
// Bounds + span constraints — text inputs are the cleanest UX (no
|
|
|
// hand-rolled "today | custom" modes). Each text becomes a DateValue
|
|
|
// via parseDateInput; presets reset both at once.
|
|
|
let minInput = $state(toDateInput(defaultMin));
|
|
|
let maxInput = $state(toDateInput(defaultMax));
|
|
|
let minDaysInput = $state('');
|
|
|
let maxDaysInput = $state('14');
|
|
|
|
|
|
let allowSingleDay = $state(true);
|
|
|
let preventDeselect = $state(false);
|
|
|
let pagedNavigation = $state(false);
|
|
|
let fixedWeeks = $state(true);
|
|
|
let numberOfMonths = $state(1);
|
|
|
let showWeekNumbers = $state(false);
|
|
|
let disableDaysOutsideMonth = $state(false);
|
|
|
let disabled = $state(false);
|
|
|
let readonly = $state(false);
|
|
|
let weekStartsOn = $state<WeekStartControl>('1');
|
|
|
let locale = $state('en-US');
|
|
|
let size = $state<RangeCalendarSize>('md');
|
|
|
let variant = $state<RangeCalendarVariant>('surface');
|
|
|
let color = $state<RangeCalendarColor>('primary');
|
|
|
|
|
|
const compiled = compileMorfo(rangeCalendarMorfo);
|
|
|
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
|
|
const events = $derived([...compiled.actions.byName.values()]);
|
|
|
|
|
|
const resolvedWeekStartsOn = $derived(
|
|
|
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
|
|
|
);
|
|
|
const selectionMinValue = $derived(parseDateInput(minInput));
|
|
|
const selectionMaxValue = $derived(parseDateInput(maxInput));
|
|
|
const minDays = $derived(parseOptionalInt(minDaysInput));
|
|
|
const maxDays = $derived(parseOptionalInt(maxDaysInput));
|
|
|
|
|
|
function toDateInput(date: DateValue): string {
|
|
|
return [
|
|
|
String(date.year).padStart(4, '0'),
|
|
|
String(date.month).padStart(2, '0'),
|
|
|
String(date.day).padStart(2, '0')
|
|
|
].join('-');
|
|
|
}
|
|
|
|
|
|
function parseDateInput(text: string): DateValue | undefined {
|
|
|
if (!text) return undefined;
|
|
|
const [year, month, day] = text.split('-').map(Number);
|
|
|
if (!year || !month || !day) return undefined;
|
|
|
return new CalendarDate(year, month, day);
|
|
|
}
|
|
|
|
|
|
function parseOptionalInt(text: string): number | undefined {
|
|
|
if (!text) return undefined;
|
|
|
const value = Number.parseInt(text, 10);
|
|
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
|
}
|
|
|
|
|
|
function setBoundsPreset(preset: 'none' | 'min' | 'max' | 'window' | 'invalid'): void {
|
|
|
if (preset === 'none') {
|
|
|
minInput = '';
|
|
|
maxInput = '';
|
|
|
} else if (preset === 'min') {
|
|
|
minInput = toDateInput(defaultMin);
|
|
|
maxInput = '';
|
|
|
} else if (preset === 'max') {
|
|
|
minInput = '';
|
|
|
maxInput = toDateInput(defaultMax);
|
|
|
} else if (preset === 'window') {
|
|
|
minInput = toDateInput(defaultMin);
|
|
|
maxInput = toDateInput(defaultMax);
|
|
|
} else if (preset === 'invalid') {
|
|
|
minInput = toDateInput(defaultMin);
|
|
|
maxInput = toDateInput(invalidMax);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const rangeLabel = $derived.by(() => {
|
|
|
if (!value.start && !value.end) return 'empty';
|
|
|
if (value.start && !value.end) return `${value.start.toString()} → …`;
|
|
|
if (value.start && value.end) return `${value.start.toString()} → ${value.end.toString()}`;
|
|
|
return '—';
|
|
|
});
|
|
|
|
|
|
$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();
|
|
|
});
|
|
|
|
|
|
function fmtTime(at: number): string {
|
|
|
const d = new Date(at);
|
|
|
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
|
}
|
|
|
|
|
|
type EmitSignal = Parameters<NonNullable<typeof uix.events>['emit']>[0];
|
|
|
|
|
|
function emitSelectStart(signal: EmitSignal) {
|
|
|
void uix.events?.emit(signal);
|
|
|
}
|
|
|
|
|
|
const eidosSnippet = $derived(
|
|
|
[
|
|
|
"<script lang='ts'>",
|
|
|
" import { RangeCalendar } from '$uix/eidos/components/range-calendar';",
|
|
|
" import type { DateRange } from '$libs/days';",
|
|
|
' let value = $state<DateRange>({ start, end });',
|
|
|
'</' + 'script>',
|
|
|
'',
|
|
|
'<RangeCalendar',
|
|
|
' bind:value',
|
|
|
size !== 'md' && ` size="${size}"`,
|
|
|
variant !== 'surface' && ` variant="${variant}"`,
|
|
|
color !== 'primary' && ` color="${color}"`,
|
|
|
numberOfMonths !== 1 && ` numberOfMonths={${numberOfMonths}}`,
|
|
|
minDays !== undefined && ` minDays={${minDays}}`,
|
|
|
maxDays !== undefined && ` maxDays={${maxDays}}`,
|
|
|
!allowSingleDay && ' allowSingleDay={false}',
|
|
|
preventDeselect && ' preventDeselect',
|
|
|
pagedNavigation && ' pagedNavigation',
|
|
|
!fixedWeeks && ' fixedWeeks={false}',
|
|
|
showWeekNumbers && ' showWeekNumbers',
|
|
|
disableDaysOutsideMonth && ' disableDaysOutsideMonth',
|
|
|
disabled && ' disabled',
|
|
|
readonly && ' readonly',
|
|
|
'>',
|
|
|
' <RangeCalendar.Header>',
|
|
|
' <RangeCalendar.PrevButton />',
|
|
|
' <RangeCalendar.Heading />',
|
|
|
' <RangeCalendar.NextButton />',
|
|
|
' </RangeCalendar.Header>',
|
|
|
' <RangeCalendar.Grid>',
|
|
|
' <RangeCalendar.GridHead>',
|
|
|
' <RangeCalendar.GridRow>',
|
|
|
' {#snippet children({ weekdays })}',
|
|
|
' {#each weekdays as wd}<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>{/each}',
|
|
|
' {/snippet}',
|
|
|
' </RangeCalendar.GridRow>',
|
|
|
' </RangeCalendar.GridHead>',
|
|
|
' <RangeCalendar.GridBody>',
|
|
|
' <!-- rows iterate via the months snippet prop -->',
|
|
|
' </RangeCalendar.GridBody>',
|
|
|
' </RangeCalendar.Grid>',
|
|
|
'</RangeCalendar>'
|
|
|
]
|
|
|
.filter(Boolean)
|
|
|
.join('\n')
|
|
|
);
|
|
|
|
|
|
function setNumberOfMonths(n: number) {
|
|
|
numberOfMonths = n;
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<div data-uix-canvas-inner>
|
|
|
<header>
|
|
|
<div data-uix-eyebrow>Forms · RangeCalendar</div>
|
|
|
<h1 data-uix-page-title>Range calendar</h1>
|
|
|
<p data-uix-page-lede>
|
|
|
Calendar primitive for picking a date range. Soma owns the state
|
|
|
machine (start → preview → end), the keyboard nav, the min/max
|
|
|
constraints (<code>minValue</code>, <code>maxValue</code>,
|
|
|
<code>minDays</code>, <code>maxDays</code>) and the hover-preview
|
|
|
signaling. Eidos paints the cells with distinct
|
|
|
<code>data-range-start</code> / <code>data-range-end</code> /
|
|
|
<code>data-in-range</code> / <code>data-in-preview-range</code>
|
|
|
treatments — the shared partial at
|
|
|
<code>range-calendar.css</code> is consumed by both this standalone
|
|
|
component and by <code>DateRangePicker</code>'s popover content.
|
|
|
</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>sizes</span>4
|
|
|
</span>
|
|
|
<span data-uix-meta-pill>
|
|
|
<span data-uix-meta-key>apg</span>
|
|
|
<a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">grid ↗</a>
|
|
|
</span>
|
|
|
<span data-uix-meta-pill>
|
|
|
<span data-uix-meta-key>range</span> {rangeLabel}
|
|
|
</span>
|
|
|
</div>
|
|
|
</header>
|
|
|
|
|
|
<div data-uix-stage>
|
|
|
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
|
|
|
<RangeCalendar
|
|
|
bind:value
|
|
|
placeholder={placeholder as never}
|
|
|
{minDays}
|
|
|
{maxDays}
|
|
|
{allowSingleDay}
|
|
|
{preventDeselect}
|
|
|
{pagedNavigation}
|
|
|
{fixedWeeks}
|
|
|
{numberOfMonths}
|
|
|
{showWeekNumbers}
|
|
|
{disableDaysOutsideMonth}
|
|
|
{disabled}
|
|
|
{readonly}
|
|
|
minValue={selectionMinValue}
|
|
|
maxValue={selectionMaxValue}
|
|
|
weekStartsOn={resolvedWeekStartsOn}
|
|
|
{locale}
|
|
|
{size}
|
|
|
{variant}
|
|
|
{color}
|
|
|
>
|
|
|
{#snippet children({ months, weekdays }: RangeCalendarProviderSnippetProps)}
|
|
|
<RangeCalendar.Header>
|
|
|
<RangeCalendar.PrevButton aria-label="Previous month">‹</RangeCalendar.PrevButton>
|
|
|
<RangeCalendar.Heading />
|
|
|
<RangeCalendar.NextButton aria-label="Next month">›</RangeCalendar.NextButton>
|
|
|
</RangeCalendar.Header>
|
|
|
<!-- Multi-month layout: grids sit side-by-side horizontally via
|
|
|
`grid-auto-flow: column` (mirrors the DateRangePicker demo).
|
|
|
When numberOfMonths=1 this collapses to a single grid track. -->
|
|
|
<div class="range-months" data-month-count={months.length}>
|
|
|
{#each months as month (month.value.toString())}
|
|
|
<div class="range-month-panel">
|
|
|
<RangeCalendar.Grid>
|
|
|
<RangeCalendar.GridHead>
|
|
|
<RangeCalendar.GridRow>
|
|
|
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
|
|
|
{#each weekdays as wd, i (i)}
|
|
|
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
|
|
|
{/each}
|
|
|
{/snippet}
|
|
|
</RangeCalendar.GridRow>
|
|
|
</RangeCalendar.GridHead>
|
|
|
<RangeCalendar.GridBody>
|
|
|
{#each month.weeks as week (week[0].toString())}
|
|
|
<RangeCalendar.GridRow week={week}>
|
|
|
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
|
|
|
{#each week as date (date.toString())}
|
|
|
<RangeCalendar.Cell {date} month={month.value}>
|
|
|
<RangeCalendar.Day />
|
|
|
</RangeCalendar.Cell>
|
|
|
{/each}
|
|
|
{/snippet}
|
|
|
</RangeCalendar.GridRow>
|
|
|
{/each}
|
|
|
</RangeCalendar.GridBody>
|
|
|
</RangeCalendar.Grid>
|
|
|
</div>
|
|
|
{/each}
|
|
|
</div>
|
|
|
{/snippet}
|
|
|
</RangeCalendar>
|
|
|
</div>
|
|
|
<div data-uix-stage-trace>
|
|
|
<span data-uix-stage-trace-key>trace</span>
|
|
|
{#if trace.length === 0}
|
|
|
<span>click a day to start; hover to preview; click second to commit</span>
|
|
|
{:else}
|
|
|
{#each trace.slice(0, 3) as entry (entry.at)}
|
|
|
<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>range</span> {rangeLabel}
|
|
|
</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>14</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>
|
|
|
Soma owns the range state machine, the keyboard navigation, the
|
|
|
min/max constraints and the hover-preview signaling. Eidos adds
|
|
|
variant/size/color cascades.
|
|
|
</p>
|
|
|
|
|
|
<div data-uix-subsection-head>
|
|
|
<span data-uix-layer-badge="soma">soma</span> range constraints
|
|
|
</div>
|
|
|
<div data-uix-controls>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>minValue <span data-uix-control-hint>{selectionMinValue?.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>{selectionMaxValue?.toString() ?? 'none'}</span></span>
|
|
|
<input type="date" bind:value={maxInput} />
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>min/max presets</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
<button data-uix-chip data-active={!minInput && !maxInput} onclick={() => setBoundsPreset('none')}>none</button>
|
|
|
<button data-uix-chip data-active={!!minInput && !maxInput} onclick={() => setBoundsPreset('min')}>min only</button>
|
|
|
<button data-uix-chip data-active={!minInput && !!maxInput} onclick={() => setBoundsPreset('max')}>max only</button>
|
|
|
<button data-uix-chip data-active={minInput === toDateInput(defaultMin) && maxInput === toDateInput(defaultMax)} onclick={() => setBoundsPreset('window')}>May-Jul</button>
|
|
|
<button data-uix-chip data-active={maxInput === toDateInput(invalidMax)} onclick={() => setBoundsPreset('invalid')}>invalid</button>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>minDays <span data-uix-control-hint>{minDays ?? 'none'}</span></span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
<button data-uix-chip data-active={minDaysInput === ''} onclick={() => (minDaysInput = '')}>none</button>
|
|
|
{#each ['2', '3', '7', '14'] as n (n)}
|
|
|
<button data-uix-chip data-active={minDaysInput === n} onclick={() => (minDaysInput = n)}>{n}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
<input type="number" min="1" placeholder="—" bind:value={minDaysInput} style="inline-size: 4rem;" />
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>maxDays <span data-uix-control-hint>{maxDays ?? 'none'}</span></span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
<button data-uix-chip data-active={maxDaysInput === ''} onclick={() => (maxDaysInput = '')}>none</button>
|
|
|
{#each ['7', '14', '30', '90'] as n (n)}
|
|
|
<button data-uix-chip data-active={maxDaysInput === n} onclick={() => (maxDaysInput = n)}>{n}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
<input type="number" min="1" placeholder="—" bind:value={maxDaysInput} style="inline-size: 4rem;" />
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>allowSingleDay</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={allowSingleDay} />
|
|
|
<span data-uix-switch-label>{allowSingleDay ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>preventDeselect</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={preventDeselect} />
|
|
|
<span data-uix-switch-label>{preventDeselect ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-subsection-head>
|
|
|
<span data-uix-layer-badge="soma">soma</span> grid layout
|
|
|
</div>
|
|
|
<div data-uix-controls>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>numberOfMonths</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each [1, 2, 3] as n (n)}
|
|
|
<button data-uix-chip data-active={numberOfMonths === n} onclick={() => setNumberOfMonths(n)}>{n}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>pagedNavigation</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={pagedNavigation} />
|
|
|
<span data-uix-switch-label>{pagedNavigation ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>fixedWeeks</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={fixedWeeks} />
|
|
|
<span data-uix-switch-label>{fixedWeeks ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>showWeekNumbers</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={showWeekNumbers} />
|
|
|
<span data-uix-switch-label>{showWeekNumbers ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>disableDaysOutsideMonth</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={disableDaysOutsideMonth} />
|
|
|
<span data-uix-switch-label>{disableDaysOutsideMonth ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>weekStartsOn</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each ['locale', '0', '1'] as opt (opt)}
|
|
|
<button data-uix-chip data-active={weekStartsOn === opt} onclick={() => (weekStartsOn = opt as WeekStartControl)}>{opt}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>disabled</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={disabled} />
|
|
|
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>readonly</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={readonly} />
|
|
|
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-subsection-head>
|
|
|
<span data-uix-layer-badge="eidos">eidos</span> visual props
|
|
|
</div>
|
|
|
<div data-uix-controls>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>variant</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each ['surface', 'outline', 'ghost'] as v (v)}
|
|
|
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as RangeCalendarVariant)}>{v}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>size</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each ['xs', 'sm', 'md', 'lg'] as s (s)}
|
|
|
<button data-uix-chip data-active={size === s} onclick={() => (size = s as RangeCalendarSize)}>{s}</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', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
|
|
|
<button data-uix-chip data-active={color === c} onclick={() => (color = c as RangeCalendarColor)}>{c}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-code>
|
|
|
<div data-uix-code-head>
|
|
|
<span data-uix-layer-badge="eidos">eidos</span>
|
|
|
<span>composition · with bind:value</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>14 parts: Provider, Header, Heading, PrevButton, NextButton, MonthSelect, YearSelect, Grid, GridHead, GridBody, GridRow, HeadCell, Cell, Day.</p>
|
|
|
|
|
|
<div data-uix-subsection-head>Provider (RangeCalendar)</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Prop</th><th>Type</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 class="type">DateRange</td><td class="default">{`{}`}</td><td>Committed range.</td></tr>
|
|
|
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">DateValue</td><td class="default empty">today</td><td>Month currently visible.</td></tr>
|
|
|
<tr><td class="name">onValueChange</td><td class="type">(r: DateRange) => void</td><td class="default empty">—</td><td>Fires when end is committed.</td></tr>
|
|
|
<tr><td class="name">minValue · maxValue</td><td class="type">DateValue</td><td class="default empty">—</td><td>Allowed bounds.</td></tr>
|
|
|
<tr><td class="name">minDays · maxDays</td><td class="type">number</td><td class="default empty">—</td><td>Inclusive length constraints (days).</td></tr>
|
|
|
<tr><td class="name">allowSingleDay</td><td class="type">boolean</td><td class="default">true</td><td>Permit start === end.</td></tr>
|
|
|
<tr><td class="name">preventDeselect</td><td class="type">boolean</td><td class="default">false</td><td>Clicking the selected endpoint keeps the range.</td></tr>
|
|
|
<tr><td class="name">numberOfMonths</td><td class="type">number</td><td class="default">1</td><td>Visible months.</td></tr>
|
|
|
<tr><td class="name">pagedNavigation</td><td class="type">boolean</td><td class="default">false</td><td>Prev/Next advance by numberOfMonths.</td></tr>
|
|
|
<tr><td class="name">fixedWeeks</td><td class="type">boolean</td><td class="default">false</td><td>Always render 6 rows.</td></tr>
|
|
|
<tr><td class="name">disableDaysOutsideMonth</td><td class="type">boolean</td><td class="default">false</td><td>Skip non-current-month cells from selection.</td></tr>
|
|
|
<tr><td class="name">disabled · readonly</td><td class="type">boolean</td><td class="default">false</td><td>OR-merged with Field.Provider.</td></tr>
|
|
|
<tr><td class="name">isDateDisabled · isDateUnavailable · isDateHoliday</td><td class="type">DateMatcher</td><td class="default empty">—</td><td>Per-cell decorators.</td></tr>
|
|
|
<tr><td class="name">weekStartsOn · weekdayFormat · locale · dir</td><td class="type">—</td><td class="default empty">locale</td><td>Localization.</td></tr>
|
|
|
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Day cells + nav buttons + selects.</td></tr>
|
|
|
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Calendar chrome.</td></tr>
|
|
|
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>End-endpoint + in-range fill + focus accent.</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-subsection-head>Sub-parts</div>
|
|
|
<p data-uix-section-desc style="margin-top: 0.25rem;">
|
|
|
The sub-parts (<code>Header</code>, <code>Heading</code>,
|
|
|
<code>PrevButton</code>, <code>NextButton</code>,
|
|
|
<code>MonthSelect</code>, <code>YearSelect</code>,
|
|
|
<code>Grid</code>, <code>GridHead</code>, <code>GridBody</code>,
|
|
|
<code>GridRow</code>, <code>HeadCell</code>, <code>Cell</code>,
|
|
|
<code>Day</code>) are pass-throughs to soma — they accept the
|
|
|
headless props from the soma `RangeCalendarXxxProps` types
|
|
|
unchanged. See the morfo table below for the per-part data
|
|
|
contract.
|
|
|
</p>
|
|
|
</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/range-calendar.ts</code>.</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">"{rangeCalendarMorfo.name}"</td></tr>
|
|
|
<tr><td class="name">kebab</td><td class="type">"{rangeCalendarMorfo.kebab}"</td></tr>
|
|
|
<tr><td class="name">apg</td><td><a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">{rangeCalendarMorfo.apg}</a></td></tr>
|
|
|
<tr><td class="name">scope</td><td class="default">{rangeCalendarMorfo.scope.join(', ')}</td></tr>
|
|
|
<tr><td class="name">parts.length</td><td class="default">{rangeCalendarMorfo.parts.length}</td></tr>
|
|
|
<tr><td class="name">events.length</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 (part.kebab)}
|
|
|
<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>
|
|
|
|
|
|
<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 events as action (action.name)}
|
|
|
{@const sem = action.semantic}
|
|
|
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
|
|
|
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
|
|
|
<tr>
|
|
|
<td class="name">{action.name}</td>
|
|
|
<td class="type">{sem.family}</td>
|
|
|
<td>{sem.verb ?? '—'}</td>
|
|
|
<td>{sem.sequence ?? 'pre'}</td>
|
|
|
<td class="default">{intentStr}</td>
|
|
|
<td>{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
|
|
|
</h2>
|
|
|
<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>Play</th></tr></thead>
|
|
|
<tbody>
|
|
|
{#each events as action (action.name)}
|
|
|
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
|
|
|
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
|
|
|
<tr>
|
|
|
<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">{effectiveIntent ?? '—'}</td>
|
|
|
<td>
|
|
|
<button
|
|
|
data-uix-play
|
|
|
onclick={() => {
|
|
|
const target = (stageRef?.querySelector('[data-range-calendar]') ?? stageRef) as HTMLElement | null;
|
|
|
if (!target) return;
|
|
|
emitSelectStart({
|
|
|
name: action.name,
|
|
|
family: action.semantic.family,
|
|
|
target,
|
|
|
...(effectiveIntent ? { intent: effectiveIntent } : {})
|
|
|
});
|
|
|
}}
|
|
|
>▶ play</button>
|
|
|
</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 at <code>src/uix/eidos/components/range-calendar/range-calendar.css</code>.
|
|
|
This file is consumed BOTH by the standalone eidos
|
|
|
<code><RangeCalendar></code> and by
|
|
|
<code><DateRangePicker></code>'s popover content — the
|
|
|
same <code>[data-range-calendar-*]</code> attrs land in both
|
|
|
environments. The picker recipe only adds scoping overrides
|
|
|
(no chrome, max-content sizing, transparent background).
|
|
|
</p>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">[data-range-calendar]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Root chrome — border + bg + padding + token scaffolding.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Day-cell + control-button size scale (xs/sm/md/lg).</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Surface / outline / ghost chrome.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>End-endpoint + in-range + focus accent tokens.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-range-start]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Start endpoint: affirm-family solid + left-edge stripe + semibold.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-range-end]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>End endpoint: accent-family solid + right-edge stripe.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-range-start][data-range-end]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Single-day range: diagonal-split gradient (start ↘ end).</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-cell][data-in-range]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>In-range cells get accent-track background fill on the cell.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-in-preview-range]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Hover-preview state while user is choosing end (transparent + accent border).</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-today]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Today marker (border ring + semibold).</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-outside-month]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Outside-month dimming.</td></tr>
|
|
|
<tr><td class="name">[data-range-calendar-day][data-disabled] · [data-unavailable]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>not-allowed cursor + reduced opacity (+ line-through for unavailable).</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>
|
|
|
Follows the APG <a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">grid pattern</a>
|
|
|
with localized labels for range-start / range-end / in-range
|
|
|
announced via <code>aria-label</code> additions on the relevant
|
|
|
day cells.
|
|
|
</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>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span></td><td>Move focus ±1 day.</td></tr>
|
|
|
<tr><td><span data-uix-kbd>ArrowUp</span> / <span data-uix-kbd>ArrowDown</span></td><td>Move focus ±1 week.</td></tr>
|
|
|
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>Move focus ±1 month.</td></tr>
|
|
|
<tr><td><span data-uix-kbd>Shift+PageUp</span> / <span data-uix-kbd>Shift+PageDown</span></td><td>Move focus ±1 year.</td></tr>
|
|
|
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last day of the visible week.</td></tr>
|
|
|
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Commit start (first press) or end (second press).</td></tr>
|
|
|
<tr><td><span data-uix-kbd>Escape</span></td><td>Cancel the in-progress range; revert to last committed.</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">provider</td><td>role</td><td class="type">"application"</td></tr>
|
|
|
<tr><td class="name">provider</td><td>aria-label</td><td class="type">localized "Range calendar"</td></tr>
|
|
|
<tr><td class="name">grid</td><td>role</td><td class="type">"grid"</td></tr>
|
|
|
<tr><td class="name">cell</td><td>role · aria-selected</td><td class="type">"gridcell" / "true|false"</td></tr>
|
|
|
<tr><td class="name">day</td><td>aria-label</td><td class="type">"date — range start | range end | in range"</td></tr>
|
|
|
<tr><td class="name">prev-button · next-button</td><td>aria-label</td><td class="type">"Previous month" / "Next month"</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
<style>
|
|
|
/*
|
|
|
* Multi-month layout — mirrors the date-range-picker demo
|
|
|
* (web/routes/uix/components/date-range-picker/+page.svelte). Side-by-side
|
|
|
* via `grid-auto-flow: column` so the grids never stack vertically; the
|
|
|
* single-month case collapses to one row.
|
|
|
*/
|
|
|
.range-months {
|
|
|
display: grid;
|
|
|
grid-auto-flow: column;
|
|
|
grid-auto-columns: max-content;
|
|
|
justify-content: center;
|
|
|
gap: var(--calendar-month-gap, 36px);
|
|
|
max-inline-size: 100%;
|
|
|
overflow-x: auto;
|
|
|
}
|
|
|
|
|
|
.range-months[data-month-count='1'] {
|
|
|
grid-auto-flow: row;
|
|
|
}
|
|
|
|
|
|
.range-month-panel {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
align-items: center;
|
|
|
gap: var(--calendar-grid-gap);
|
|
|
min-inline-size: max-content;
|
|
|
}
|
|
|
</style>
|