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/month-grid/+page.svelte

496 lines
20 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 {
MonthGrid,
type MonthGridColor,
type MonthGridProviderSnippetProps,
type MonthGridSize,
type MonthGridVariant,
type MonthValue
} from '$uix/eidos/components/month-grid';
import { compileMorfo } from '$uix/morfo';
import { monthGridMorfo } from '@/uix/morfo/components/month-grid';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
type MorfoEventView = {
name: string;
target: string;
semantic: { family: string; verb?: string; sequence?: string; intent?: unknown };
};
const uix = getActiveUix();
type EmitSignal = Parameters<NonNullable<typeof uix.events>['emit']>[0];
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<MonthValue | undefined>({ year: 2026, month: 5 });
let placeholder = $state<{ year: number; month?: number }>({ year: 2026 });
let disabled = $state(false);
let readonly = $state(false);
let monthFormat = $state<'long' | 'short' | 'narrow'>('short');
let locale = $state('en-US');
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<MonthGridSize>('md');
let variant = $state<MonthGridVariant>('surface');
let color = $state<MonthGridColor>('primary');
let useMin = $state(false);
let useMax = $state(false);
const compiled = compileMorfo(monthGridMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()] as MorfoEventView[]);
const minValue = $derived<MonthValue | undefined>(useMin ? { year: 2026, month: 3 } : undefined);
const maxValue = $derived<MonthValue | undefined>(useMax ? { year: 2026, month: 10 } : undefined);
const valueLabel = $derived(value ? `${value.year}-${value.month}` : 'empty');
const placeholderLabel = $derived(`${placeholder.year}`);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function playEvent(action: MorfoEventView): void {
const intent = typeof action.semantic.intent === 'string' ? action.semantic.intent : undefined;
const signal: EmitSignal = {
name: action.name,
family: action.semantic.family as EmitSignal['family'],
target: stageRef ?? document.body
};
if (intent) (signal as unknown as { intent: string }).intent = intent;
uix.events?.emit(signal);
}
$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 MonthGrid from '$soma/components/month-grid';",
'',
` let value = $state<{ year: number; month: number } | undefined>(${value ? `{ year: ${value.year}, month: ${value.month} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<MonthGrid.Provider',
' bind:value',
' bind:placeholder',
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year}, month: ${minValue.month} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year}, month: ${maxValue.month} }}`,
monthFormat !== 'short' && ` monthFormat="${monthFormat}"`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ months })}',
' <MonthGrid.Header>',
' <MonthGrid.PrevButton>‹</MonthGrid.PrevButton>',
' <MonthGrid.Heading />',
' <MonthGrid.NextButton>›</MonthGrid.NextButton>',
' </MonthGrid.Header>',
' <MonthGrid.Grid>',
' {#each months as month (month)}',
' <MonthGrid.Cell {month} />',
' {/each}',
' </MonthGrid.Grid>',
' {/snippet}',
'</MonthGrid.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { MonthGrid } from '$uix/eidos/components/month-grid';",
'',
` let value = $state<{ year: number; month: number } | undefined>(${value ? `{ year: ${value.year}, month: ${value.month} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<MonthGrid',
' bind:value',
' bind:placeholder',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year}, month: ${minValue.month} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year}, month: ${maxValue.month} }}`,
monthFormat !== 'short' && ` monthFormat="${monthFormat}"`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ months })}',
' <MonthGrid.Header>',
' <MonthGrid.PrevButton>‹</MonthGrid.PrevButton>',
' <MonthGrid.Heading />',
' <MonthGrid.NextButton>›</MonthGrid.NextButton>',
' </MonthGrid.Header>',
' <MonthGrid.Grid>',
' {#each months as month (month)}',
' <MonthGrid.Cell {month} />',
' {/each}',
' </MonthGrid.Grid>',
' {/snippet}',
'</MonthGrid>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · MonthGrid</div>
<h1 data-uix-page-title>MonthGrid</h1>
<p data-uix-page-lede>
Standalone month picker — 12 cells (Jan..Dec) for a single year, prev/next year navigation,
full keyboard support and morfo-declared semantic events. Parallel to the inline
Calendar's month view but works without a calendar context.
</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>year</span>{placeholderLabel}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<MonthGrid
bind:value
bind:placeholder
{disabled}
{readonly}
{minValue}
{maxValue}
{monthFormat}
{locale}
{dir}
{size}
{variant}
{color}
>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Header>
<MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet}
</MonthGrid>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a month or use prev/next</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>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</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>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value, placeholder, bounds
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>{valueLabel}</span></span>
<span data-uix-chips role="group">
<button data-uix-chip data-active={value?.month === 5} onclick={() => (value = { year: 2026, month: 5 })}>May 2026</button>
<button data-uix-chip data-active={value?.month === 6} onclick={() => (value = { year: 2026, month: 6 })}>Jun 2026</button>
<button data-uix-chip data-active={value === undefined} onclick={() => (value = undefined)}>empty</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placeholder year</span>
<span data-uix-chips role="radiogroup">
{#each [2025, 2026, 2027] as y (y)}
<button data-uix-chip data-active={placeholder.year === y} onclick={() => (placeholder = { year: y })}>{y}</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={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>bounds</span>
<span data-uix-switch><input type="checkbox" bind:checked={useMin} /><span data-uix-switch-label>min=Mar 2026</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={useMax} /><span data-uix-switch-label>max=Oct 2026</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>monthFormat</span>
<span data-uix-chips role="radiogroup">
{#each ['long', 'short', 'narrow'] as v (v)}
<button data-uix-chip data-active={monthFormat === v} onclick={() => (monthFormat = v as typeof monthFormat)}>{v}</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 v (v)}
<button data-uix-chip data-active={dir === v} onclick={() => (dir = v as 'ltr' | 'rtl')}>{v}</button>
{/each}
</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 ['sm', 'md', 'lg'] as v (v)}
<button data-uix-chip data-active={size === v} onclick={() => (size = v as MonthGridSize)}>{v}</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 v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as MonthGridVariant)}>{v}</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 v (v)}
<button data-uix-chip data-active={color === v} onclick={() => (color = v as MonthGridColor)}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · standalone month picker</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 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>
<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 `{`{ year, month }`}` pair.</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">today's year</td><td>Anchor year for the 12-month grid.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default empty">-</td><td>Disables cells outside the bound.</td></tr>
<tr><td class="name">disabled / readonly</td><td>soma</td><td class="default">false</td><td>Root state.</td></tr>
<tr><td class="name">monthFormat</td><td>soma</td><td class="default">'short'</td><td>Intl month-name length.</td></tr>
<tr><td class="name">locale / dir</td><td>soma</td><td class="default">services</td><td>Label formatting and directional arrow 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>Visual scale.</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>Surface treatment.</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 selected month.</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>Ark UI</th><th>React Aria</th><th>Chakra</th><th>Bits UI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Standalone month grid</td><td>part of DatePicker</td><td>via composition</td><td>no</td><td>via Calendar</td><td>first-class</td></tr>
<tr><td class="name">Bounds (min/max)</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Keyboard nav</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Locale labels</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Semantic events</td><td>callbacks</td><td>callbacks</td><td>-</td><td>callbacks</td><td>commit-set / nav-step</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>
<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>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.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<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>Target</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = action.semantic.intent}
<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">{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}</td>
<td class="type">{action.target}</td>
<td><button data-uix-chip onclick={() => playEvent(action)}>▶ 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>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-month-grid]</code></td><td>Root surface, size and variant tokens.</td></tr>
<tr><td class="name"><code>[data-month-grid-header]</code></td><td>Three-column nav header (prev · heading · next).</td></tr>
<tr><td class="name"><code>[data-month-grid-grid]</code></td><td>3×4 month grid.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-selected]</code></td><td>Selected month tile.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-current]</code></td><td>Today's month accent.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-disabled]</code></td><td>Out of bounds / disabled.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<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>←</span> / <span data-uix-kbd>→</span></td><td>Move focus ±1 month (RTL-flipped).</td></tr>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>↓</span></td><td>Move focus ±3 months (row width).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last month of year.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>±1 year (also moves placeholder).</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Select focused month.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>APG</div>
<p>Follows <a href="https://www.w3.org/WAI/ARIA/apg/patterns/grid/" rel="noreferrer" target="_blank">WAI-ARIA APG Grid</a> pattern: <code>role="grid"</code> on the grid container, <code>role="gridcell"</code> + roving <code>tabindex</code> on cells.</p>
</section>
{/if}
</div>

Powered by TurnKey Linux.