feat(eidos): port time-range-field from soma

Eidos wrapper for time range selection — start/end TimeField inputs
sharing a single ZonedDateTime range value. Mirrors DateRangeField
pattern with `data-time-field` overlay trick so item segments inherit
the time-field recipe.

## Feature comparison

| Feature              | react-aria | Mantine | Eidos |
|----------------------|------------|---------|-------|
| Range value          | ✓          | ✓       | ✓     |
| Per-endpoint Input   | ✓          | ✓       | ✓     |
| Segment editing      | ✓          | —       | ✓     |
| Granularity prop     | ✓          | ✓       | ✓     |
| Hour cycle (12/24)   | ✓          | ✓       | ✓     |
| Min/max value        | ✓          | ✓       | ✓     |
| Placeholder per-end  | ✓          | —       | ✓     |
| Invalid signalling   | ✓          | ✓       | ✓     |
| Disabled / readOnly  | ✓          | ✓       | ✓     |
| Form integration     | ✓          | ✓       | ✓     |
| Size cascade         | —          | ✓       | ✓ xs–xl |
| Variant cascade      | —          | ✓       | ✓ 3   |
| Color cascade        | —          | —       | ✓ 6   |

## Parts

Root · Label · Input (type='start' | 'end') · Segment (re-exported
from TimeField). Each Input scopes its own TimeField context so
segment editing routes to the correct endpoint.

## Recipe

Grid `1fr · auto · 1fr` with em-dash separator. Specificity bump on
`[data-time-range-field][data-time-field]` to win over the overlay
data-time-field rules.

## Demo

Canonical 6-tab depth (~530L): Live / API / Morfo / Sema / Recipe /
A11y. Variant / size / color / granularity / hour-cycle / min-max /
disabled / readOnly / required controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 67726cb6f3
commit db21767db1

@ -0,0 +1,59 @@
// TimeRangeField — eidos compound API.
//
// import { TimeRangeField } from '$uix/eidos/components/time-range-field';
//
// <TimeRangeField bind:value variant="surface" size="md">
// <TimeRangeField.Label>Window</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>
//
// Segment is reused from TimeField's eidos wrapper — each endpoint
// Input scopes its own TimeField context so segment resolution still
// targets the right endpoint. Recipe is layout-only: the chrome on
// each endpoint Input comes from `time-field.css` because the root
// overlays `data-time-field` on `data-time-range-field` (same trick
// as DateRangeField).
import TimeRangeFieldComponent from './time-range-field.svelte';
import Input from './time-range-field-input.svelte';
import Label from './time-range-field-label.svelte';
import TimeField from '$uix/eidos/components/time-field';
const Segment = TimeField.Segment;
type TimeRangeFieldNamespace = typeof TimeRangeFieldComponent & {
Label: typeof Label;
Input: typeof Input;
Segment: typeof Segment;
};
const TimeRangeField = TimeRangeFieldComponent as TimeRangeFieldNamespace;
TimeRangeField.Label = Label;
TimeRangeField.Input = Input;
TimeRangeField.Segment = Segment;
export { TimeRangeField };
export default TimeRangeField;
export type {
TimeRangeFieldProps,
TimeRangeFieldLabelProps as LabelProps,
TimeRangeFieldInputProps as InputProps,
TimeRangeFieldSegmentProps as SegmentProps,
TimeRangeFieldSize,
TimeRangeFieldVariant,
TimeRangeFieldColor,
InputSnippetProps
} from './types';

@ -0,0 +1,18 @@
<script lang="ts">
/**
* Eidos `<TimeRangeField.Input>`. Pass-through over soma's Input
* which creates its own TimeField context per endpoint. Snippet
* args (`segments`) come from soma unchanged — consumers iterate
* and render `<TimeRangeField.Segment>` per slot.
*/
import * as TimeRangeField from '$soma/components/time-range-field';
import type { TimeRangeFieldInputProps } from './types';
let { children: bodyContent, ...rest }: TimeRangeFieldInputProps = $props();
</script>
<TimeRangeField.Input {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</TimeRangeField.Input>

@ -0,0 +1,8 @@
<script lang="ts">
import * as TimeRangeField from '$soma/components/time-range-field';
import type { TimeRangeFieldLabelProps } from './types';
let { children, ...rest }: TimeRangeFieldLabelProps = $props();
</script>
<TimeRangeField.Label {...rest}>{@render children?.()}</TimeRangeField.Label>

@ -0,0 +1,92 @@
/*
* TimeRangeField recipe — layout only.
*
* The chrome of each endpoint input is painted by `time-field.css`
* because:
*
* 1. The eidos `<TimeRangeField>` root overlays `data-time-field=""`
* on top of `data-time-range-field`, so the existing
* `[data-time-field] { --_*: … }` token setup, size cascades,
* variant cascades and color tokens all apply inside the range.
*
* 2. soma's `<TimeRangeField.Input>` overlays `data-time-field-input`
* on each endpoint container (the runtime composes both markers
* on the same element). So `[data-time-field-input]` chrome rules
* paint each endpoint exactly like a standalone TimeField.
*
* Same trick `DateRangeField` uses with `data-date-field`. This recipe
* only adds the range-specific layout:
*
* - Label sits above the inputs, spans the full row
* - Two inputs side-by-side via grid (1fr · auto · 1fr)
* - Em-dash separator in the middle grid cell
* - `data-endpoint='start'` / `data-endpoint='end'` available for
* consumer-side tinting; the default recipe leaves it inert.
*
* [data-time-range-field] → grid wrapper (also data-time-field)
* [data-time-range-field-label] → label, spans the full row
* [data-time-range-field-input] → endpoint container
* (also data-time-field-input)
*/
/*
* Specificity bump (0, 2, 0) so we beat `[data-time-field]`'s base
* rule from `time-field.css` — that rule sets
* `display: inline-flex; flex-direction: column;` which would stack
* the inputs instead of placing them on a 1fr · auto · 1fr grid.
* The eidos wrapper overlays both markers on the same element; this
* recipe makes the grid layout deterministic regardless of CSS
* import order.
*/
[data-time-range-field][data-time-field] {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
column-gap: var(--time-range-field-row-gap, var(--space-3));
row-gap: var(--time-field-stack-gap, var(--space-1-5));
align-items: center;
inline-size: 100%;
min-inline-size: 0;
}
[data-time-range-field-label] {
grid-column: 1 / -1;
grid-row: 1;
font-family: var(--time-field-label-font-family, var(--style-label-font-family));
font-size: var(--time-field-label-font-size, var(--font-size-sm));
font-weight: var(--time-field-label-font-weight, 500);
color: var(--time-field-label-color, var(--color-content-primary));
}
[data-time-range-field-input] {
grid-row: 2;
min-inline-size: 0;
}
[data-time-range-field-input][data-endpoint='start'] {
grid-column: 1;
}
[data-time-range-field-input][data-endpoint='end'] {
grid-column: 3;
}
/* Em-dash separator between the two inputs. Anchored to the grid's
* middle column / second row. */
[data-time-range-field]:has(
[data-time-range-field-input][data-endpoint='start']
):has([data-time-range-field-input][data-endpoint='end'])::before {
content: var(--time-range-field-separator-glyph, '—');
grid-column: 2;
grid-row: 2;
color: var(--time-range-field-separator-color, var(--color-content-muted));
font-family: var(--style-label-font-family, var(--font-ui));
font-size: 0.9em; /* literal: separator scales with the field font-size */
user-select: none;
pointer-events: none;
}
[data-time-range-field][data-disabled] {
opacity: var(--time-field-disabled-opacity, 0.55);
pointer-events: none;
}

@ -0,0 +1,48 @@
<script lang="ts">
/**
* Eidos `<TimeRangeField>` — root. Sets the visual cascade
* (data-size / data-variant / data-color) that both endpoint
* Inputs and their Segments inherit. The morfo emits
* `data-time-range-field` on the container.
*/
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangeField from '$soma/components/time-range-field';
import type { TimeRangeFieldProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(),
placeholder = $bindable(),
children,
...rest
}: TimeRangeFieldProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<!--
Overlay `data-time-field=""` on top of the morfo's
`data-time-range-field` so the whole `time-field.css` cascade
(chrome on `[data-time-field-input]`, variant cascade on
`[data-time-field][data-variant=…] [data-time-field-input]`, size
vars on `[data-time-field][data-size=…]`, color tokens on
`[data-time-field][data-color=…]`) applies inside the range without
duplicating any of those rules. soma's `TimeRangeField.Input`
already overlays `data-time-field-input` on each endpoint, so the
selectors match end-to-end with zero recipe duplication. Same trick
as `DateRangeField` uses with `data-date-field`.
-->
<TimeRangeField.Provider
{...rest}
bind:value
bind:placeholder
data-time-field=""
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{@render children?.()}
</TimeRangeField.Provider>

@ -0,0 +1,57 @@
import type {
ProviderProps,
LabelProps,
InputProps,
InputSnippetProps,
SegmentProps
} from '$soma/components/time-range-field';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { TimeFieldSize, TimeFieldVariant, TimeFieldColor } from '$uix/eidos/components/time-field';
/**
* Eidos `<TimeRangeField>` — visual wrapper over the headless
* time-range-field. Two endpoint inputs (`type='start'` /
* `type='end'`), one shared Label, and the same Segment used by
* time-field for each editable slot.
*
* <TimeRangeField bind:value variant="surface" size="md">
* <TimeRangeField.Label>Window</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>
*
* Visual concerns reuse the TimeField vocabulary (size / variant /
* color) — the start and end Inputs read the cascaded data-attrs from
* the root, identical to how DateRangeField wraps DateField.
*/
export type TimeRangeFieldSize = TimeFieldSize;
export type TimeRangeFieldVariant = TimeFieldVariant;
export type TimeRangeFieldColor = TimeFieldColor;
export type TimeRangeFieldProps = ProviderProps & {
/** Visual treatment. Mirrors TimeField. @default 'surface' */
variant?: TimeRangeFieldVariant;
/** Color tone. @default 'primary' */
color?: TimeRangeFieldColor;
/** Sizing scale. @default 'md' */
size?: ResponsiveProp<TimeRangeFieldSize>;
};
export type TimeRangeFieldLabelProps = LabelProps;
export type TimeRangeFieldInputProps = InputProps;
export type TimeRangeFieldSegmentProps = SegmentProps;
export type { InputSnippetProps };

@ -99,6 +99,7 @@
@import './components/listbox/listbox.css';
@import './components/navigation-menu/navigation-menu.css';
@import './components/grid-list/grid-list.css';
@import './components/time-range-field/time-range-field.css';
/* Shared menu-item indicator partial — must load AFTER the menu recipes
so its `padding-inline-start` calc wins over the row rule's
`padding-inline` shorthand at equal specificity. */

@ -158,6 +158,7 @@
{ slug: '/uix/components/date-picker', label: 'Date picker' },
{ slug: '/uix/components/date-range-picker', label: 'Date range picker' },
{ slug: '/uix/components/time-field', label: 'Time field' },
{ slug: '/uix/components/time-range-field', label: 'Time range field' },
{ slug: '/uix/components/time-picker', label: 'Time picker' },
{ slug: '/uix/components/time-range-picker', label: 'Time range picker' },
{ slug: '/uix/components/color-field', label: 'Color field' },

@ -0,0 +1,539 @@
<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">
{#snippet children({ segments })}
{#each segments as seg (seg.part)}
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>
{/each}
{/snippet}
</TimeRangeField.Input>
<TimeRangeField.Input type="end">
{#snippet children({ segments })}
{#each segments as seg (seg.part)}
<TimeRangeField.Segment part={seg.part}>{seg.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>
<span data-uix-chips role="radiogroup">
{#each ['en-US', 'en-GB', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP'] as l (l)}
<button data-uix-chip data-active={locale === l} onclick={() => (locale = l)}>{l}</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 d (d)}
<button data-uix-chip data-active={dir === d} onclick={() => (dir = d as Direction)}>{d}</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>
Loading…
Cancel
Save

Powered by TurnKey Linux.