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

777 lines
34 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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) =&gt; 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">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<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>&lt;RangeCalendar&gt;</code> and by
<code>&lt;DateRangePicker&gt;</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>

Powered by TurnKey Linux.