From db21767db12f4c4875b9559eef86210e75e6222e Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 18:48:26 +0200 Subject: [PATCH] feat(eidos): port time-range-field from soma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/time-range-field/index.ts | 59 ++ .../time-range-field-input.svelte | 18 + .../time-range-field-label.svelte | 8 + .../time-range-field/time-range-field.css | 92 +++ .../time-range-field/time-range-field.svelte | 48 ++ .../components/time-range-field/types.ts | 57 ++ src/uix/eidos/index.css | 1 + web/routes/uix/+layout@.svelte | 1 + .../components/time-range-field/+page.svelte | 539 ++++++++++++++++++ 9 files changed, 823 insertions(+) create mode 100644 src/uix/eidos/components/time-range-field/index.ts create mode 100644 src/uix/eidos/components/time-range-field/time-range-field-input.svelte create mode 100644 src/uix/eidos/components/time-range-field/time-range-field-label.svelte create mode 100644 src/uix/eidos/components/time-range-field/time-range-field.css create mode 100644 src/uix/eidos/components/time-range-field/time-range-field.svelte create mode 100644 src/uix/eidos/components/time-range-field/types.ts create mode 100644 web/routes/uix/components/time-range-field/+page.svelte diff --git a/src/uix/eidos/components/time-range-field/index.ts b/src/uix/eidos/components/time-range-field/index.ts new file mode 100644 index 000000000..35d2bca21 --- /dev/null +++ b/src/uix/eidos/components/time-range-field/index.ts @@ -0,0 +1,59 @@ +// TimeRangeField — eidos compound API. +// +// import { TimeRangeField } from '$uix/eidos/components/time-range-field'; +// +// +// Window +// +// {#snippet children({ segments })} +// {#each segments as seg} +// {seg.value} +// {/each} +// {/snippet} +// +// +// {#snippet children({ segments })} +// {#each segments as seg} +// {seg.value} +// {/each} +// {/snippet} +// +// +// +// 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'; diff --git a/src/uix/eidos/components/time-range-field/time-range-field-input.svelte b/src/uix/eidos/components/time-range-field/time-range-field-input.svelte new file mode 100644 index 000000000..f1dcacb44 --- /dev/null +++ b/src/uix/eidos/components/time-range-field/time-range-field-input.svelte @@ -0,0 +1,18 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/time-range-field/time-range-field-label.svelte b/src/uix/eidos/components/time-range-field/time-range-field-label.svelte new file mode 100644 index 000000000..609c9708f --- /dev/null +++ b/src/uix/eidos/components/time-range-field/time-range-field-label.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} diff --git a/src/uix/eidos/components/time-range-field/time-range-field.css b/src/uix/eidos/components/time-range-field/time-range-field.css new file mode 100644 index 000000000..bdafe91bf --- /dev/null +++ b/src/uix/eidos/components/time-range-field/time-range-field.css @@ -0,0 +1,92 @@ +/* + * TimeRangeField recipe — layout only. + * + * The chrome of each endpoint input is painted by `time-field.css` + * because: + * + * 1. The eidos `` 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 `` 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; +} diff --git a/src/uix/eidos/components/time-range-field/time-range-field.svelte b/src/uix/eidos/components/time-range-field/time-range-field.svelte new file mode 100644 index 000000000..7b541236d --- /dev/null +++ b/src/uix/eidos/components/time-range-field/time-range-field.svelte @@ -0,0 +1,48 @@ + + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-range-field/types.ts b/src/uix/eidos/components/time-range-field/types.ts new file mode 100644 index 000000000..3b25fc367 --- /dev/null +++ b/src/uix/eidos/components/time-range-field/types.ts @@ -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 `` — 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. + * + * + * Window + * + * {#snippet children({ segments })} + * {#each segments as seg} + * {seg.value} + * {/each} + * {/snippet} + * + * + * {#snippet children({ segments })} + * {#each segments as seg} + * {seg.value} + * {/each} + * {/snippet} + * + * + * + * 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; +}; + +export type TimeRangeFieldLabelProps = LabelProps; +export type TimeRangeFieldInputProps = InputProps; +export type TimeRangeFieldSegmentProps = SegmentProps; + +export type { InputSnippetProps }; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 3858cf54e..8691f4a20 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -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. */ diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 736e95c2c..3cff0d39e 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' }, diff --git a/web/routes/uix/components/time-range-field/+page.svelte b/web/routes/uix/components/time-range-field/+page.svelte new file mode 100644 index 000000000..5857e1850 --- /dev/null +++ b/web/routes/uix/components/time-range-field/+page.svelte @@ -0,0 +1,539 @@ + + +
+
+
Forms · TimeRangeField
+

TimeRangeField

+

+ Companion of TimeField for time ranges + ({`{ start, end }`}). 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 time-field.css — + the eidos root overlays data-time-field on top of + data-time-range-field so the entire size / variant / + color cascade applies inside the range without recipe + duplication. This recipe only adds the + 1fr · auto · 1fr grid layout + em-dash separator. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + sizes5 + + + value {valueLabel} + +
+
+ +
+
+
+ + {#if showLabel} + {labelText} + {/if} + + {#snippet children({ segments })} + {#each segments as seg (seg.part)} + {seg.value} + {/each} + {/snippet} + + + {#snippet children({ segments })} + {#each segments as seg (seg.part)} + {seg.value} + {/each} + {/snippet} + + +
+
+
+ trace + {#if trace.length === 0} + tab into a segment and arrow up/down to edit + {:else} + {#each trace.slice(0, 3) as entry (entry.at)} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value {valueLabel} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+ +
+ soma format +
+
+ + + + + + + + +
+ +
+ eidos visual props +
+
+ + + +
+ +
Composition
+
+ + +
+ +
+
+ eidos + visual · grid layout, em-dash separator, chrome from time-field.css + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

4 parts: Provider, Label, Input (start/end), Segment.

+ +
Provider
+
+ + + + + + + + + + + + + + + +
PropTypeDefaultDescription
value bindableTimeRange | null—{`{ start, end }`} time pair.
placeholderTimeValue—Used to seed segments before any value is set.
granularity'hour' | 'minute' | 'second''minute'Smallest segment.
hourCycle12 | 24locale12h vs 24h presentation.
hideTimeZonebooleanfalseSuppress timezone segment for ZonedDateTime values.
minValue · maxValueTimeValue—Validation range applied to BOTH endpoints.
disabled · readonly · requiredbooleanfalseOR-merged with enclosing Field.Provider.
locale · dirBCP47 · 'ltr' | 'rtl'soma prefs—
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Visual size cascade (reuses TimeField tokens).
variant eidosControlVariant'surface'Input chrome treatment.
color eidosColorRole'primary'Focus + selection accent.
+
+ +
Input
+
+ + + + + + + +
PropTypeDefaultDescription
type'start' | 'end'requiredWhich endpoint this input represents.
namestring—Hidden form input name (per endpoint).
aria-labelstring—Override accessible name.
+
+ +
Label · Segment
+

+ Label sits above the two inputs (grid row 1, full + width). Segment is re-exported from + TimeField — each endpoint's Input + scopes its own TimeField context so segment editing routes to + the right endpoint without ambiguity. +

+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

Source: src/uix/morfo/components/time-range-field.ts.

+ +
+ + + + + + + + +
FieldValue
name"{timeRangeFieldMorfo.name}"
kebab"{timeRangeFieldMorfo.kebab}"
parts.length{timeRangeFieldMorfo.parts.length}
events.length{events.length}
+
+ +
Parts
+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events +

+

Events fire on segment commit (per endpoint).

+ +
+ + + + {#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 ?? '?'}` : '—'} + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntent
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{intentStr}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Layout-only at src/uix/eidos/components/time-range-field/time-range-field.css. + Chrome on each endpoint Input comes from time-field.css because the + eidos root overlays data-time-field="" on top of + data-time-range-field — the entire size / variant / color cascade + applies inside the range without duplicating any rules. +

+
+ + + + + + + + + +
SelectorSourceWhat it paints
[data-time-range-field][data-time-field]eidosGrid wrapper (1fr · auto · 1fr).
[data-time-range-field-label]morfoLabel spanning full row.
[data-time-range-field-input][data-endpoint='start' | 'end']morfoEndpoint container — grid columns 1 / 3.
[data-time-range-field]::beforeeidosEm-dash separator in middle column.
[data-time-field-input], [data-time-field-segment]eidosInherited from time-field.css — each endpoint paints like a standalone TimeField.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ Same ARIA contract as TimeField, applied per endpoint. Each + segment is a role="spinbutton" with + aria-valuemin / aria-valuemax / + aria-valuenow driven by the resolved value; + Tab moves across segments AND across the start↔end boundary. +

+ +
Keyboard
+
+ + + + + + + + + + +
KeyAction
ArrowUp / ArrowDownIncrement / decrement the focused segment.
ArrowLeft / ArrowRightMove focus to previous / next segment (crosses start↔end).
Home / EndFirst / last editable segment of the focused endpoint.
DigitType to fill the segment; auto-advances on overflow.
BackspaceClear digit-by-digit.
TabAdvance focus across endpoint segments.
+
+
+ {/if} +