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

952 lines
44 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 {
TimeField,
type TimeFieldColor,
type TimeFieldSize,
type TimeFieldVariant,
type InputSnippetProps
} from '$uix/eidos/components/time-field';
import { compileMorfo } from '$uix/morfo';
import { timeFieldMorfo } from '@/uix/morfo/components/time-field';
import { getActiveUix } from '$active-uix';
import {
Time,
ZonedDateTime,
type TimeValue,
type TimeGranularity,
type HourCycle,
type EditableTimeSegmentPart
} 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';
type ValueProfile = 'time' | 'zoned';
const uix = getActiveUix();
const startTime: TimeValue = new Time(9, 30, 0);
const endTime: TimeValue = new Time(17, 45, 30);
const zonedTime: TimeValue = new ZonedDateTime(2026, 5, 23, 'America/New_York', -14400000, 9, 30, 0);
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ────────────────────────────────────────────────────────
// Soma · Value + format
let valueProfile = $state<ValueProfile>('time');
let value = $state<TimeValue | undefined>(startTime);
let placeholder = $state<TimeValue>(startTime);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let hideTimeZone = $state(false);
// Soma · Bounds + validation
let bounds = $state(true);
const minValue: TimeValue = new Time(8, 0, 0);
const maxValue: TimeValue = new Time(20, 0, 0);
// Soma · Readonly segments
let lockHour = $state(false);
let lockMinute = $state(false);
let lockSecond = $state(false);
// Soma · Flags
let required = $state(true);
let disabled = $state(false);
let readonly = $state(false);
let locale = $state('en-US');
let dir = $state<Direction>('ltr');
// Soma · Input
let name = $state('start');
let ariaLabel = $state('Start time');
// Composition
let showLabel = $state(true);
// Demo content
let labelText = $state('Start');
// Eidos
let size = $state<TimeFieldSize>('md');
let variant = $state<TimeFieldVariant>('surface');
let color = $state<TimeFieldColor>('primary');
const compiled = compileMorfo(timeFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const readonlySegments = $derived<EditableTimeSegmentPart[]>([
...(lockHour ? (['hour'] as const) : []),
...(lockMinute ? (['minute'] as const) : []),
...(lockSecond ? (['second'] as const) : [])
]);
const valueLabel = $derived(value?.toString() ?? 'empty');
function sameValue(a: TimeValue | undefined, b: TimeValue): boolean {
return a?.toString() === b.toString();
}
function setDemoValue(kind: 'start' | 'end' | 'empty'): void {
if (kind === 'empty') {
value = undefined;
return;
}
value = kind === 'start' ? startTime : endTime;
}
function setProfile(p: ValueProfile): void {
valueProfile = p;
if (p === 'zoned') {
value = zonedTime;
placeholder = zonedTime;
} else {
value = startTime;
placeholder = startTime;
}
}
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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 TimeField from '$soma/components/time-field';",
valueProfile === 'zoned'
? " import { ZonedDateTime, type TimeValue } from '$libs/days';"
: " import { Time, type TimeValue } from '$libs/days';",
'',
valueProfile === 'zoned'
? ' const placeholder = new ZonedDateTime(2026, 5, 23, \'America/New_York\', -14400000, 9, 30, 0);'
: ' const placeholder = new Time(9, 30, 0);',
' let value = $state<TimeValue | undefined>(placeholder);',
'</' + 'script>',
'',
'<TimeField.Provider',
' bind:value',
' {placeholder}',
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
bounds && ' minValue={new Time(8, 0, 0)}',
bounds && ' maxValue={new Time(20, 0, 0)}',
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
valueProfile === 'zoned' && hideTimeZone && ' hideTimeZone',
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
readonlySegments.length > 0 &&
` readonlySegments={${JSON.stringify(readonlySegments)}}`,
'>',
showLabel && ` <TimeField.Label>${labelText}</TimeField.Label>`,
` <TimeField.Input name="${name}" aria-label="${ariaLabel}">`,
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimeField.Segment {part}>{value}</TimeField.Segment>',
' {/each}',
' {/snippet}',
' </TimeField.Input>',
'</TimeField.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { TimeField } from '$uix/eidos/components/time-field';",
valueProfile === 'zoned'
? " import { ZonedDateTime, type TimeValue } from '$libs/days';"
: " import { Time, type TimeValue } from '$libs/days';",
'',
valueProfile === 'zoned'
? ' const placeholder = new ZonedDateTime(2026, 5, 23, \'America/New_York\', -14400000, 9, 30, 0);'
: ' const placeholder = new Time(9, 30, 0);',
' let value = $state<TimeValue | undefined>(placeholder);',
'</' + 'script>',
'',
'<TimeField',
' bind:value',
' {placeholder}',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
bounds && ' minValue={new Time(8, 0, 0)}',
bounds && ' maxValue={new Time(20, 0, 0)}',
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
valueProfile === 'zoned' && hideTimeZone && ' hideTimeZone',
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
readonlySegments.length > 0 &&
` readonlySegments={${JSON.stringify(readonlySegments)}}`,
'>',
showLabel && ` <TimeField.Label>${labelText}</TimeField.Label>`,
` <TimeField.Input name="${name}" aria-label="${ariaLabel}">`,
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimeField.Segment {part}>{value}</TimeField.Segment>',
' {/each}',
' {/snippet}',
' </TimeField.Input>',
'</TimeField>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Form · TimeField</div>
<h1 data-uix-page-title>TimeField</h1>
<p data-uix-page-lede>
Segmented time input. A bordered group contains per-segment
spinbuttons (hour, minute, second, dayPeriod, timeZone) that locale-
and clock-aware (12/24-hour). <span data-uix-layer-badge="soma">soma</span>
owns segment editing, granularity, hourCycle, validation;
<span data-uix-layer-badge="eidos">eidos</span> adds size / variant /
color recipe. 1 doctrinal event (<code>commit-set</code>).
Field-aware: <code>disabled</code> / <code>readonly</code> /
<code>required</code> OR-merge with the enclosing
<code>Field.Provider</code>; validation via custom
<code>validate</code> callback or external invalid signal from
the Field context. Accepts <code>Time</code> and
<code>ZonedDateTime</code> values.
</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>granularity</span>3
</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>apg</span>
<a href={timeFieldMorfo.apg} target="_blank" rel="noreferrer">spinbutton ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span>{valueLabel}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<TimeField
bind:value
bind:placeholder
{required}
{disabled}
{readonly}
{readonlySegments}
{granularity}
{hourCycle}
{hideTimeZone}
{locale}
{dir}
minValue={bounds ? minValue : undefined}
maxValue={bounds ? maxValue : undefined}
{size}
{variant}
{color}
>
{#if showLabel}<TimeField.Label>{labelText}</TimeField.Label>{/if}
<TimeField.Input {name} aria-label={ariaLabel}>
{#snippet children({ segments }: InputSnippetProps)}
{#each segments as { part, value }, index (`${part}-${index}`)}
<TimeField.Segment {part}>{value}</TimeField.Segment>
{/each}
{/snippet}
</TimeField.Input>
</TimeField>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>edit a segment or play an event to see sema stamps</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 data-uix-stage-trace-key style="margin-inline-start: 0.5rem;">granularity</span> {granularity}
</span>
</div>
</div>
<!-- Tabs -->
<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>5</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Props grouped by the layer that owns them.
<span data-uix-layer-badge="soma">soma</span> owns value, segment
editing, granularity, hourCycle, bounds;
<span data-uix-layer-badge="eidos">eidos</span> owns visual
treatment. Sema events live in the
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
</p>
<!-- ── Soma · Value + format ────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Value + format
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>bindable</span></span>
<span data-uix-chips role="group">
<button data-uix-chip data-active={sameValue(value, startTime)} onclick={() => setDemoValue('start')}>09:30</button>
<button data-uix-chip data-active={sameValue(value, endTime)} onclick={() => setDemoValue('end')}>17:45:30</button>
<button data-uix-chip data-active={value === undefined} onclick={() => setDemoValue('empty')}>empty</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>value profile</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={valueProfile === 'time'} onclick={() => setProfile('time')}>Time</button>
<button data-uix-chip data-active={valueProfile === 'zoned'} onclick={() => setProfile('zoned')}>ZonedDateTime</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>granularity</span>
<span data-uix-chips role="radiogroup">
{#each ['hour', 'minute', 'second'] as opt (opt)}
<button data-uix-chip data-active={granularity === opt} onclick={() => (granularity = opt as TimeGranularity)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hourCycle</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={hourCycle === 12} onclick={() => (hourCycle = 12)}>12 (AM/PM)</button>
<button data-uix-chip data-active={hourCycle === 24} onclick={() => (hourCycle = 24)}>24</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hideTimeZone <span data-uix-control-hint>ZonedDateTime only</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={hideTimeZone} disabled={valueProfile !== 'zoned'} />
<span data-uix-switch-label>{hideTimeZone ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<span data-uix-chips role="radiogroup">
{#each ['en-US', 'en-GB', 'es-ES', 'fr-FR', 'ja-JP'] as opt (opt)}
<button data-uix-chip data-active={locale === opt} onclick={() => (locale = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>dir</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as opt (opt)}
<button data-uix-chip data-active={dir === opt} onclick={() => (dir = opt as Direction)}>{opt}</button>
{/each}
</span>
</label>
</div>
<!-- ── Soma · Bounds + per-segment readonly ─────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Bounds + segment locks
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>min/maxValue <span data-uix-control-hint>08:00 — 20:00</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={bounds} />
<span data-uix-switch-label>{bounds ? 'enforced' : 'unbounded'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hour readonly</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lockHour} />
<span data-uix-switch-label>{lockHour ? 'locked' : 'editable'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>minute readonly</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lockMinute} />
<span data-uix-switch-label>{lockMinute ? 'locked' : 'editable'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>second readonly</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lockSecond} disabled={granularity !== 'second'} />
<span data-uix-switch-label>{lockSecond ? 'locked' : 'editable'}</span>
</span>
</label>
</div>
<!-- ── Soma · Flags ─────────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Flags
<span style="font-weight: normal; color: var(--uix-text-muted);">
· OR-merge with Field.Provider
</span>
</div>
<div data-uix-controls>
<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>
<!-- ── Soma · Input identity ────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Input · DOM identity
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>name <span data-uix-control-hint>form submission</span></span>
<input type="text" bind:value={name} />
</label>
<label data-uix-control>
<span data-uix-control-label>aria-label</span>
<input type="text" bind:value={ariaLabel} />
</label>
</div>
<!-- ── Eidos · visual ───────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> Visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as opt (opt)}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt as TimeFieldSize)}>{opt}</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 opt (opt)}
<button data-uix-chip data-active={variant === opt} onclick={() => (variant = opt as TimeFieldVariant)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color <span data-uix-control-hint>focus affordance</span></span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as opt (opt)}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt as TimeFieldColor)}>{opt}</button>
{/each}
</span>
</label>
</div>
<!-- ── Composition ──────────────────────────────────────────────── -->
<div data-uix-subsection-head>
Composition
<span style="font-weight: normal; color: var(--uix-text-muted);">
· toggle parts in / out of the markup
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>Label</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showLabel} />
<span data-uix-switch-label>{showLabel ? 'on' : 'off'}</span>
</span>
</label>
</div>
<!-- ── Demo content ─────────────────────────────────────────────── -->
<div data-uix-subsection-head>
Demo content
<span style="font-weight: normal; color: var(--uix-text-muted);">
· literal label text
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>Label text</span>
<input type="text" bind:value={labelText} disabled={!showLabel} />
</label>
</div>
<!-- ── Code snippets ───────────────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · segmented editing + granularity + hourCycle</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 · adds size, variant, color recipe</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>
Five parts: <code>TimeField</code> (Provider root),
<code>Label</code>, <code>Input</code> (segment group),
<code>Segment</code> (per part), <code>HiddenInput</code>
(form submission, rendered automatically when <code>name</code>
is set).
</p>
<div data-uix-subsection-head>TimeField (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">id</td><td class="type">string</td><td class="default empty">auto</td><td>Stable identifier.</td></tr>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">TimeValue (Time | ZonedDateTime) | undefined</td><td class="default empty">—</td><td>Current time. Set undefined for empty.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(v: TimeValue | undefined) =&gt; void</td><td class="default empty">—</td><td>—</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">TimeValue</td><td class="default empty">—</td><td>Anchor for empty-segment arrow-up; drives default shape (granularity, time-zone presence).</td></tr>
<tr><td class="name">onPlaceholderChange</td><td class="type">(v: TimeValue) =&gt; void</td><td class="default empty">—</td><td>—</td></tr>
<tr><td class="name">validate</td><td class="type">(v: TimeValue) =&gt; string[] | string | void</td><td class="default empty">—</td><td>Custom validator.</td></tr>
<tr><td class="name">onInvalid</td><td class="type">(reason, msg?) =&gt; void</td><td class="default empty">—</td><td>Fires when validation fails.</td></tr>
<tr><td class="name">minValue / maxValue</td><td class="type">TimeValue</td><td class="default empty">—</td><td>Earliest / latest acceptable time.</td></tr>
<tr><td class="name">granularity</td><td class="type">'hour' | 'minute' | 'second'</td><td class="default">'minute'</td><td>Finest visible segment.</td></tr>
<tr><td class="name">hourCycle</td><td class="type">12 | 24</td><td class="default empty">soma / locale</td><td>Force 12-hour AM/PM or 24-hour. Falls back to <code>soma.dates.getHourCycle()</code> then locale default.</td></tr>
<tr><td class="name">hideTimeZone</td><td class="type">boolean</td><td class="default">false</td><td>Hide tz segment on <code>ZonedDateTime</code> values.</td></tr>
<tr><td class="name">readonlySegments</td><td class="type">EditableTimeSegmentPart[]</td><td class="default">[]</td><td>Per-segment readonly subset (e.g. <code>['hour']</code> locks hour).</td></tr>
<tr><td class="name">disabled <span data-uix-tag data-kind="field">field-OR</span></td><td class="type">boolean</td><td class="default">false</td><td>OR-merge with Field.Provider.</td></tr>
<tr><td class="name">readonly <span data-uix-tag data-kind="field">field-OR</span></td><td class="type">boolean</td><td class="default">false</td><td>—</td></tr>
<tr><td class="name">required <span data-uix-tag data-kind="field">field-OR</span></td><td class="type">boolean</td><td class="default">false</td><td>HiddenInput required.</td></tr>
<tr><td class="name">errorMessageId</td><td class="type">string</td><td class="default empty">—</td><td>External error element id (wired via <code>aria-describedby</code>).</td></tr>
<tr><td class="name">locale</td><td class="type">string (BCP-47)</td><td class="default empty">soma config</td><td>Drives segment order + dayPeriod literal.</td></tr>
<tr><td class="name">dir</td><td class="type">'ltr' | 'rtl'</td><td class="default empty">soma config</td><td>Keyboard navigation direction.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'xs' | 'sm' | 'md' | 'lg' | 'xl'&gt;</td><td class="default">'md'</td><td>Input height, segment padding, text size.</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>Visual treatment.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">8 system tokens</td><td class="default">'primary'</td><td>Intent palette for focus affordance.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>TimeField.Label</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">id</td><td class="type">string</td><td class="default empty">auto</td><td>Used by Input's <code>aria-labelledby</code>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>TimeField.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">id</td><td class="type">string</td><td class="default empty">auto</td><td>—</td></tr>
<tr><td class="name">name</td><td class="type">string</td><td class="default empty">—</td><td>HiddenInput name (emits ISO string when set).</td></tr>
<tr><td class="name">aria-label / aria-labelledby</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. Prefer <code>aria-labelledby</code> with a Label part.</td></tr>
<tr><td class="name">children <span data-uix-tag data-kind="required">required</span></td><td class="type">{`Snippet<[{ segments }]>`}</td><td class="default empty">—</td><td>Iterate <code>segments</code> from snippet props.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>TimeField.Segment</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">part <span data-uix-tag data-kind="required">required</span></td><td class="type">TimeSegmentPart</td><td class="default empty">—</td><td>'hour' | 'minute' | 'second' | 'dayPeriod' | 'timeZoneName' | 'literal'.</td></tr>
<tr><td class="name">id</td><td class="type">string</td><td class="default empty">auto</td><td>—</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>TimeField.HiddenInput</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
Rendered automatically by <code>Input</code> when
<code>name</code> is set. Submits the ISO 8601 time string;
required when the field is required.
</p>
<div data-uix-subsection-head>Reference parity</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>react-aria</th><th>ark-ui</th><th>Chakra v3</th><th>bits-ui</th><th>Mantine</th><th>Ant</th><th>Eidos (this)</th></tr></thead>
<tbody>
<tr><td>Segmented spinbutton editing</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>granularity (hour/minute/second)</td><td>✓</td><td>✓</td><td>—</td><td>—</td><td>✓</td><td>✓ (format)</td><td>✓</td></tr>
<tr><td>hourCycle 12 / 24</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td><td>partial</td><td>✓</td></tr>
<tr><td>ZonedDateTime + hideTimeZone</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>min/max bounds</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td>Per-segment readonly</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>Custom validator + invalid flag</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>Field integration (Field-OR)</td><td>partial</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>commit-set semantic event</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>
4 parts (Provider, Input, Segment, Label), 1 event
(<code>commit-set</code>). The Segment part inherits the
WAI-ARIA Spinbutton pattern — each segment is its own focusable
spinbutton with min/max/value/valueText. Source:
<code>src/uix/morfo/components/time-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">"{timeFieldMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{timeFieldMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{timeFieldMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={timeFieldMorfo.apg} target="_blank" rel="noreferrer">{timeFieldMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{timeFieldMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{timeFieldMorfo.events?.length ?? 0}</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>
{#each timeFieldMorfo.parts as rawPart (rawPart.kebab)}
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>; keyboard?: ReadonlyArray<{ key: string; action: string }> }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{@const keyboard = partAny.keyboard ?? []}
{#if dataAttrs.length || ariaAttrs.length || keyboard.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr (attr.attr)}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a (a.attr)}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if keyboard.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>key</th><th>action</th></tr></thead>
<tbody>
{#each keyboard as kb (kb.key)}
<tr>
<td><span data-uix-kbd>{kb.key === ' ' ? 'Space' : kb.key}</span></td>
<td class="default">{kb.action}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>commit-set</code> fires <em>post</em> the value mutation
with static intent <code>neutral</code>. Per-keystroke segment
edits are debounced — the event fires once when the user
commits (blur / Enter / arrow-step settled).
</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><th>target</th><th>prewrite</th><th>commit</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>
<td class="default">{action.prewrite.length ? action.prewrite.map((p) => `${p.attr}=${p.value}`).join(', ') : '—'}</td>
<td class="default">{action.commit ? `${action.commit.attr}=${action.commit.value}` : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
One event: <code>commit-set</code> from the <code>commit</code>
family with verb <code>set</code> and static intent
<code>neutral</code>. Segment editing remains headless until
the value is fully resolved — the perceptual closure lands
once per commit, not per keystroke.
</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><th>Target</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 class="default">{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const targetSel =
action.target === 'input'
? '[data-time-field-input]'
: action.target === 'segment'
? '[data-time-field-segment]'
: '[data-time-field]';
const target = (
stageRef?.querySelector(targetSel) ??
stageRef?.querySelector('[data-time-field]') ??
stageRef
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
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>
Source: <code>src/uix/eidos/components/time-field/time-field.css</code>.
Recipe shares vocabulary with date-field / color-field — same
bordered group chrome, same segment-state cascade.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Concern</th></tr></thead>
<tbody>
<tr><td class="name">[data-time-field]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Root contract + state attrs.</td></tr>
<tr><td class="name">[data-time-field][data-size='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Visual scale (height, padding, font-size).</td></tr>
<tr><td class="name">[data-time-field][data-variant='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>surface / outline / ghost treatment.</td></tr>
<tr><td class="name">[data-time-field][data-color='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Focus accent palette.</td></tr>
<tr><td class="name">[data-time-field-label]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Label typography + click-to-focus.</td></tr>
<tr><td class="name">[data-time-field-input]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Segment group container.</td></tr>
<tr><td class="name">[data-time-field-segment][data-segment='X']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Per-segment (hour / minute / second / dayPeriod / timeZoneName / literal).</td></tr>
<tr><td class="name">[data-time-field-segment]:focus / :focus-visible</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Active segment highlight.</td></tr>
<tr><td class="name">[data-time-field-segment][data-readonly]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Locked segment visual cue.</td></tr>
<tr><td class="name">[data-time-field-segment][data-placeholder]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Empty-segment placeholder color.</td></tr>
<tr><td class="name">[data-time-field][data-invalid]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Threat-toned border.</td></tr>
<tr><td class="name">[data-time-field][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Opacity + cursor lock.</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 WAI-ARIA APG <a href={timeFieldMorfo.apg} target="_blank" rel="noreferrer">spinbutton pattern</a>.
Each editable segment is a focusable spinbutton with
<code>aria-valuemin</code> / <code>valuemax</code> /
<code>valuenow</code> / <code>valuetext</code>. The Input wraps
the segments with <code>role='group'</code>; the Label (when
present) is wired via <code>aria-labelledby</code> on each
segment so screen readers announce the group name alongside
the part ("Start, hour, 9" instead of just "9").
</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 focused segment (clamped to bounds).</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span></td><td>Move focus to previous / next segment (dir-aware).</td></tr>
<tr><td>Digit keys</td><td>Type the value directly; auto-advance when the segment is filled.</td></tr>
<tr><td><span data-uix-kbd>Backspace</span></td><td>Delete current segment value (sets to empty / placeholder).</td></tr>
<tr><td><span data-uix-kbd>Tab</span> / <span data-uix-kbd>Shift+Tab</span></td><td>Move focus out of the input.</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>data-invalid / data-disabled / data-readonly / data-required</td><td class="type">resolved state</td></tr>
<tr><td class="name">label</td><td>for</td><td class="type">{`<first-segment id>`} (clicking focuses)</td></tr>
<tr><td class="name">input</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">input</td><td>aria-label / aria-labelledby</td><td class="type">{`{aria-label}`} or {`<label id>`}</td></tr>
<tr><td class="name">input</td><td>aria-disabled / aria-readonly / aria-invalid</td><td class="type">resolved state</td></tr>
<tr><td class="name">input</td><td>aria-describedby</td><td class="type">{`{errorMessageId}`} when set</td></tr>
<tr><td class="name">segment</td><td>role</td><td class="type">"spinbutton"</td></tr>
<tr><td class="name">segment</td><td>aria-label</td><td class="type">part name ("hour", "minute", "second", "AM/PM", …)</td></tr>
<tr><td class="name">segment</td><td>aria-valuemin / aria-valuemax</td><td class="type">per-segment range (0–23 for hour 24h, 1–12 for hour 12h, 0–59 for minute/second)</td></tr>
<tr><td class="name">segment</td><td>aria-valuenow / aria-valuetext</td><td class="type">current value + formatted text</td></tr>
<tr><td class="name">segment</td><td>aria-labelledby</td><td class="type">{`<label id>`} (when Label present)</td></tr>
<tr><td class="name">hidden-input</td><td>name / required</td><td class="type">{`{name}`} / mirrors required state · emits ISO time string</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.