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/time-range-field/+page.svelte

540 lines
22 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 {
TimeRangeField,
type TimeRangeFieldColor,
type TimeRangeFieldSize,
type TimeRangeFieldVariant
} from '$uix/eidos/components/time-range-field';
import { compileMorfo } from '$uix/morfo';
import { timeRangeFieldMorfo } from '@/uix/morfo/components/time-range-field';
import { getActiveUix } from '$active-uix';
import {
Time,
type TimeValue,
type TimeRange,
type TimeGranularity,
type HourCycle
} from '$libs/days';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
type Direction = 'ltr' | 'rtl';
const uix = getActiveUix();
const startTime: TimeValue = new Time(9, 30, 0);
const endTime: TimeValue = new Time(17, 45, 0);
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ────────────────────────────────────────────────────────
let value = $state<TimeRange>({ start: startTime, end: endTime });
let placeholder = $state<TimeValue>(startTime);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let hideTimeZone = $state(false);
let bounds = $state(false);
const minValue: TimeValue = new Time(6, 0, 0);
const maxValue: TimeValue = new Time(22, 0, 0);
// Per-endpoint readonly segments — not currently exposed by soma
// TimeRangeField.Input (granularity locks both endpoints uniformly).
// Tracked here for parity wishlist with DateRangeField; demo doesn't
// wire them.
let required = $state(false);
let disabled = $state(false);
let readonly = $state(false);
let locale = $state('en-US');
let dir = $state<Direction>('ltr');
// Composition
let showLabel = $state(true);
let labelText = $state('Working hours');
// Eidos
let size = $state<TimeRangeFieldSize>('md');
let variant = $state<TimeRangeFieldVariant>('surface');
let color = $state<TimeRangeFieldColor>('primary');
const compiled = compileMorfo(timeRangeFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const valueLabel = $derived(
`{ start: ${value.start?.toString() ?? '—'}, end: ${value.end?.toString() ?? '—'} }`
);
$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')}`;
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { TimeRangeField } from '$uix/eidos/components/time-range-field';",
" import { Time } from '$libs/days';",
' let value = $state({ start: new Time(9, 30), end: new Time(17, 45) });',
'</' + 'script>',
'',
'<TimeRangeField',
' bind:value',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
showLabel && ` <TimeRangeField.Label>${labelText}</TimeRangeField.Label>`,
' <TimeRangeField.Input type="start">',
' {#snippet children({ segments })}',
' {#each segments as seg}',
' <TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>',
' {/each}',
' {/snippet}',
' </TimeRangeField.Input>',
' <TimeRangeField.Input type="end">',
' {#snippet children({ segments })}',
' {#each segments as seg}',
' <TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>',
' {/each}',
' {/snippet}',
' </TimeRangeField.Input>',
'</TimeRangeField>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · TimeRangeField</div>
<h1 data-uix-page-title>TimeRangeField</h1>
<p data-uix-page-lede>
Companion of <code>TimeField</code> for time ranges
(<code>{`{ start, end }`}</code>). Two endpoint inputs share
one Label and the same Segment component; soma's
TimeRangeField provider creates a separate TimeField context per
endpoint so segment editing routes to the correct boundary.
Visual chrome is reused 1:1 from <code>time-field.css</code> —
the eidos root overlays <code>data-time-field</code> on top of
<code>data-time-range-field</code> so the entire size / variant /
color cascade applies inside the range without recipe
duplication. This recipe only adds the
<code>1fr · auto · 1fr</code> grid layout + em-dash separator.
</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>5
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span> {valueLabel}
</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;">
<div style="inline-size: min(28rem, 100%);">
<TimeRangeField
bind:value
{placeholder}
{granularity}
{hourCycle}
{hideTimeZone}
{required}
{disabled}
{readonly}
{locale}
{dir}
{size}
{variant}
{color}
minValue={bounds ? minValue : undefined}
maxValue={bounds ? maxValue : undefined}
>
{#if showLabel}
<TimeRangeField.Label>{labelText}</TimeRangeField.Label>
{/if}
<TimeRangeField.Input type="start" aria-label="Start time">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each}
{/snippet}
</TimeRangeField.Input>
<TimeRangeField.Input type="end" aria-label="End time">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each}
{/snippet}
</TimeRangeField.Input>
</TimeRangeField>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>tab into a segment and arrow up/down to edit</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>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>4</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>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> format
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>granularity</span>
<span data-uix-chips role="radiogroup">
{#each ['hour', 'minute', 'second'] as g (g)}
<button data-uix-chip data-active={granularity === g} onclick={() => (granularity = g as TimeGranularity)}>{g}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hourCycle</span>
<span data-uix-chips role="radiogroup">
{#each [12, 24] as h (h)}
<button data-uix-chip data-active={hourCycle === h} onclick={() => (hourCycle = h as HourCycle)}>{h}h</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 (value)}
<button data-uix-chip data-active={dir === value} onclick={() => (dir = value as Direction)}>{value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>bounds <span data-uix-control-hint>06:00 – 22:00</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={bounds} />
<span data-uix-switch-label>{bounds ? 'on' : 'off'}</span>
</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>
<label data-uix-control>
<span data-uix-control-label>required</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? '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 TimeRangeFieldVariant)}>{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', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as TimeRangeFieldSize)}>{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 TimeRangeFieldColor)}>{c}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Composition</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>show Label</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showLabel} />
<span data-uix-switch-label>{showLabel ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>label text</span>
<input type="text" bind:value={labelText} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · grid layout, em-dash separator, chrome from time-field.css</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>4 parts: Provider, Label, Input (start/end), Segment.</p>
<div data-uix-subsection-head>Provider</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">TimeRange | null</td><td class="default empty">—</td><td>{`{ start, end }`} time pair.</td></tr>
<tr><td class="name">placeholder</td><td class="type">TimeValue</td><td class="default empty">—</td><td>Used to seed segments before any value is set.</td></tr>
<tr><td class="name">granularity</td><td class="type">'hour' | 'minute' | 'second'</td><td class="default">'minute'</td><td>Smallest segment.</td></tr>
<tr><td class="name">hourCycle</td><td class="type">12 | 24</td><td class="default">locale</td><td>12h vs 24h presentation.</td></tr>
<tr><td class="name">hideTimeZone</td><td class="type">boolean</td><td class="default">false</td><td>Suppress timezone segment for ZonedDateTime values.</td></tr>
<tr><td class="name">minValue · maxValue</td><td class="type">TimeValue</td><td class="default empty">—</td><td>Validation range applied to BOTH endpoints.</td></tr>
<tr><td class="name">disabled · readonly · required</td><td class="type">boolean</td><td class="default">false</td><td>OR-merged with enclosing Field.Provider.</td></tr>
<tr><td class="name">locale · dir</td><td class="type">BCP47 · 'ltr' | 'rtl'</td><td class="default empty">soma prefs</td><td>—</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Visual size cascade (reuses TimeField tokens).</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant</td><td class="default">'surface'</td><td>Input chrome treatment.</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>Focus + selection accent.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Input</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">type</td><td class="type">'start' | 'end'</td><td class="default empty">required</td><td>Which endpoint this input represents.</td></tr>
<tr><td class="name">name</td><td class="type">string</td><td class="default empty">—</td><td>Hidden form input name (per endpoint).</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Override accessible name.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Label · Segment</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>Label</code> sits above the two inputs (grid row 1, full
width). <code>Segment</code> is re-exported from
<code>TimeField</code> — each endpoint's <code>Input</code>
scopes its own TimeField context so segment editing routes to
the right endpoint without ambiguity.
</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/time-range-field.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">"{timeRangeFieldMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{timeRangeFieldMorfo.kebab}"</td></tr>
<tr><td class="name">parts.length</td><td class="default">{timeRangeFieldMorfo.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>
</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>
<p data-uix-section-desc>Events fire on segment commit (per endpoint).</p>
<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></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>
</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>
Layout-only at <code>src/uix/eidos/components/time-range-field/time-range-field.css</code>.
Chrome on each endpoint Input comes from <code>time-field.css</code> because the
eidos root overlays <code>data-time-field=""</code> on top of
<code>data-time-range-field</code> — the entire size / variant / color cascade
applies inside the range without duplicating any rules.
</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-time-range-field][data-time-field]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Grid wrapper (1fr · auto · 1fr).</td></tr>
<tr><td class="name">[data-time-range-field-label]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Label spanning full row.</td></tr>
<tr><td class="name">[data-time-range-field-input][data-endpoint='start' | 'end']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Endpoint container — grid columns 1 / 3.</td></tr>
<tr><td class="name">[data-time-range-field]::before</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Em-dash separator in middle column.</td></tr>
<tr><td class="name">[data-time-field-input], [data-time-field-segment]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Inherited from time-field.css — each endpoint paints like a standalone TimeField.</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>
Same ARIA contract as TimeField, applied per endpoint. Each
segment is a <code>role="spinbutton"</code> with
<code>aria-valuemin</code> / <code>aria-valuemax</code> /
<code>aria-valuenow</code> driven by the resolved value;
Tab moves across segments AND across the start↔end boundary.
</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>ArrowUp</span> / <span data-uix-kbd>ArrowDown</span></td><td>Increment / decrement the focused segment.</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span></td><td>Move focus to previous / next segment (crosses start↔end).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last editable segment of the focused endpoint.</td></tr>
<tr><td>Digit</td><td>Type to fill the segment; auto-advances on overflow.</td></tr>
<tr><td><span data-uix-kbd>Backspace</span></td><td>Clear digit-by-digit.</td></tr>
<tr><td><span data-uix-kbd>Tab</span></td><td>Advance focus across endpoint segments.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.