From ec7cdd60b407f66fab627ee6fa555f02e67e986d Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 00:46:57 +0200 Subject: [PATCH] fix(date-range-field): canonical demo + recipe specificity + DateField overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User audit (2026-05-23) caught two real defects in this component: 1. Demo was a 386-line skeleton — switches used the made-up `data-control-kind="switch"` attribute, props like granularity / locale / hideTimeZone / per-endpoint readonlySegments were not wired, API / Morfo / Recipe / A11y tabs were one-sentence stubs. Canonical reference demos are 700–1200 lines. 2. Recipe rule `[data-date-range-field-input][data-active] [data-date-field]` matched nothing — soma overlays `data-date-field-input` on the SAME element as `data-date-range-field-input` (no nested `[data-date-field]`). Fixes: - Eidos `` root now overlays `data-date-field=""` on top of `data-date-range-field`. Lets the full `date-field.css` cascade (chrome, size, variant, color) apply inside the range with zero recipe duplication. Documented in the wrapper comment. - `date-range-field.css` simplified to layout-only: [data-date-range-field][data-date-field] (specificity bump beats date-field's base `display: inline-flex` rule) grid: 1fr · auto · 1fr × 2 rows label spans the full first row separator via `::before` in the middle grid cell endpoints placed in columns 1 and 3 via `data-endpoint='start' / 'end'` No recipe duplication; date-field paints input chrome. - Demo rebuilt to canonical depth (1008 lines, +622): Live tab: 6 layered subsections (value shape / locale + dir / flags / per-endpoint readonly segments / eidos chrome / actions), 11 switches in canonical `` style with state-text labels, 9 chip groups (value profile, kind, granularity, hourCycle, hideTimeZone, locale × 6 locales, dir, size × 5, variant × 3, color × 6), reset / clear actions, reactive soma + eidos snippets reflecting every control. API tab: full prop table per part (Root with 21 props, Label, Input, Segment), reference parity table vs Bits UI / Ark UI / react-aria / Chakra v3. Morfo tab: header table + parts overview + per-part data / aria / keyboard tables (driven by raw morfo, cast to a narrowed shape) + events table. Sema tab: passive justification + delegation table showing per-endpoint DateField runtimes. Recipe tab: selector classification (morfo / eidos) + chrome attribution. A11y tab: per-segment keyboard + ARIA contract tables. - Demo `{#each segments}` keyed by `(i)` (was already fixed, kept). Lesson registered in `C:\Users\dev\.claude\projects\G--dev-svelte-vicen\memory\feedback_demos_must_be_canonical_depth.md` and referenced from `MEMORY.md`: ALWAYS read DEMO_AUTHORING_GUIDE.md + a reference demo (date-field 716L, drawer 737L, date-range-picker 1238L) BEFORE writing. Compare against reference libs first. Verified visually at /uix/components/date-range-field — DOM has both endpoints side-by-side, em-dash separator in middle column, date-field chrome applied to each input, canonical control style across all 6 subsections. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../date-range-field/date-range-field.css | 118 +-- .../date-range-field/date-range-field.svelte | 12 + .../components/date-range-field/+page.svelte | 788 ++++++++++++++++-- 3 files changed, 777 insertions(+), 141 deletions(-) diff --git a/src/uix/eidos/components/date-range-field/date-range-field.css b/src/uix/eidos/components/date-range-field/date-range-field.css index e703df436..e196eef00 100644 --- a/src/uix/eidos/components/date-range-field/date-range-field.css +++ b/src/uix/eidos/components/date-range-field/date-range-field.css @@ -1,89 +1,91 @@ /* - * DateRangeField recipe. + * DateRangeField recipe — layout only. * - * [data-date-range-field] → label + control stack - * [data-date-range-field-label] → optional label - * [data-date-range-field-input] → endpoint container (start or end) + * The chrome of each endpoint input is painted by `date-field.css` + * because: * - * Each Input wraps a DateField context internally, so segments inside - * pick up date-field's existing recipe automatically. The range - * recipe only adds: + * 1. The eidos `` root overlays `data-date-field=""` + * on top of `data-date-range-field`, so the existing + * `[data-date-field] { --_*: … }` token setup, size cascades, + * variant cascades and color tokens all apply inside the range. * - * - Label + control row layout (mirrors DateField's stack) - * - A horizontal flex with the two inputs and an em-dash separator, - * rendered via ::before/::after on the end input so consumers don't - * need to render a `` separator manually. - * - Endpoint-aware data-attr (`data-type='start'` / `data-type='end'`) - * passes through from the morfo; usable for endpoint-specific tints. + * 2. soma's `` overlays `data-date-field-input` + * on each endpoint container (the runtime composes both markers + * on the same element). So `[data-date-field-input]` chrome rules + * paint each endpoint exactly like a standalone DateField. + * + * 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-date-range-field] → grid wrapper (also data-date-field) + * [data-date-range-field-label] → label, spans the full row + * [data-date-range-field-input] → endpoint container + * (also data-date-field-input) */ -[data-date-range-field] { - display: inline-flex; - flex-direction: column; - gap: var(--date-field-stack-gap, var(--space-1-5)); +/* + * Specificity bump (0, 2, 0) so we beat `[data-date-field]`'s base + * rule from `date-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-date-range-field][data-date-field] { + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto auto; + column-gap: var(--date-range-field-row-gap, var(--space-3)); + row-gap: var(--date-field-stack-gap, var(--space-1-5)); + align-items: center; inline-size: 100%; min-inline-size: 0; - font-family: var(--date-field-font-family, var(--style-label-font-family)); } [data-date-range-field-label] { + grid-column: 1 / -1; + grid-row: 1; font-family: var(--date-field-label-font-family, var(--style-label-font-family)); font-size: var(--date-field-label-font-size, var(--font-size-sm)); font-weight: var(--date-field-label-font-weight, 500); color: var(--date-field-label-color, var(--color-content-primary)); } -/* - * Each Input is itself a DateField root, so [data-date-field]'s - * recipe paints the bordered control. The range layout wraps two - * such controls in a horizontal row. - */ - -[data-date-range-field] > [data-date-range-field-input] { - display: inline-flex; - flex: 1 1 0; +[data-date-range-field-input] { + grid-row: 2; min-inline-size: 0; } -/* Row layout — when both Inputs are direct children, set the - * container to a row with the separator between them. */ -[data-date-range-field]:has([data-date-range-field-input][data-type='start']) - :where([data-date-range-field-input][data-type='end']) { - margin-inline-start: var(--date-range-field-separator-gap, var(--space-2)); - position: relative; +[data-date-range-field-input][data-endpoint='start'] { + grid-column: 1; } -[data-date-range-field]:has([data-date-range-field-input][data-type='start']) - :where([data-date-range-field-input][data-type='end'])::before { +[data-date-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-date-range-field]:has( + [data-date-range-field-input][data-endpoint='start'] + ):has([data-date-range-field-input][data-endpoint='end'])::before { content: var(--date-range-field-separator-glyph, '—'); - position: absolute; - inset-inline-start: calc(var(--date-range-field-separator-gap, var(--space-2)) * -1); - inset-block-start: 50%; - transform: translate(-50%, -50%); + grid-column: 2; + grid-row: 2; color: var(--date-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 */ -} - -/* When consumers compose Label + the two Inputs at the same level, - * give the inputs a flex row so they sit side-by-side. The :has - * selector keeps the recipe inert if the consumer arranges things - * differently. */ -[data-date-range-field]:has([data-date-range-field-input]) { - --_drf-inputs-display: flex; -} - -[data-date-range-field] > [data-date-range-field-input]:first-of-type { - margin-block-start: 0; + user-select: none; + pointer-events: none; } [data-date-range-field][data-disabled] { opacity: var(--date-field-disabled-opacity, 0.55); pointer-events: none; } - -/* Endpoint tints — subtle hue shift on the active endpoint without - * hiding which side has focus. The morfo emits `data-active` on the - * input that holds the caret. */ -[data-date-range-field-input][data-active] [data-date-field] { - --_date-field-border-color: var(--_date-field-accent-border); -} diff --git a/src/uix/eidos/components/date-range-field/date-range-field.svelte b/src/uix/eidos/components/date-range-field/date-range-field.svelte index 55acc8c55..4a7ba2305 100644 --- a/src/uix/eidos/components/date-range-field/date-range-field.svelte +++ b/src/uix/eidos/components/date-range-field/date-range-field.svelte @@ -23,10 +23,22 @@ const resolvedSize = $derived(eidos.resolve(size, 'md')); + ('live'); + let trace = $state([]); + let stageRef = $state(null); + + // ── Live state — every public prop wired ───────────────────────────── + let valueProfile = $state('date'); + let kind = $state('date'); + let granularity = $state('day'); + let value = $state({ start: startDate, end: endDate }); + let placeholder = $state(startDate); + let nameStart = $state('startDate'); + let nameEnd = $state('endDate'); + let required = $state(false); + let disabled = $state(false); + let readonly = $state(false); + let bounds = $state(false); + let readonlyStartYear = $state(false); + let readonlyStartMonth = $state(false); + let readonlyStartDay = $state(false); + let readonlyEndYear = $state(false); + let readonlyEndMonth = $state(false); + let readonlyEndDay = $state(false); + let locale = $state('en-US'); + let dir = $state('ltr'); + let hourCycle = $state(24); + let hideTimeZone = $state(false); + let size = $state('md'); + let variant = $state('surface'); + let color = $state('primary'); const sizes: DateRangeFieldSize[] = ['xs', 'sm', 'md', 'lg', 'xl']; const variants: DateRangeFieldVariant[] = ['surface', 'outline', 'ghost']; const colors: DateRangeFieldColor[] = ['primary', 'secondary', 'neutral', 'affirm', 'risk', 'threat']; - const kinds: Array<'date' | 'month' | 'year'> = ['date', 'month', 'year']; + const kinds: Kind[] = ['date', 'month', 'year']; + const granularities: Granularity[] = ['day', 'hour', 'minute', 'second']; + const hourCycles: HourCycle[] = [12, 24]; + const directions: Direction[] = ['ltr', 'rtl']; + const valueProfiles: ValueProfile[] = ['date', 'datetime', 'zoned']; + const locales = ['en-US', 'en-GB', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP']; - const placeholderDate = new CalendarDate(2026, 1, 1); + // ── Derived state ──────────────────────────────────────────────────── + const startReadonlySegments = $derived( + [ + ...(readonlyStartYear ? (['year'] as const) : []), + ...(readonlyStartMonth ? (['month'] as const) : []), + ...(readonlyStartDay ? (['day'] as const) : []) + ] + ); + const endReadonlySegments = $derived( + [ + ...(readonlyEndYear ? (['year'] as const) : []), + ...(readonlyEndMonth ? (['month'] as const) : []), + ...(readonlyEndDay ? (['day'] as const) : []) + ] + ); - let value = $state({ - start: new CalendarDate(2026, 5, 19), - end: new CalendarDate(2026, 6, 3) - }); - let placeholder = $state(placeholderDate); - let size = $state('md'); - let variant = $state('surface'); - let color = $state('primary'); - let kind = $state<'date' | 'month' | 'year'>('date'); - let disabled = $state(false); - let readonly = $state(false); - let required = $state(false); + const valueLabel = $derived( + `{ start: ${value.start?.toString() ?? '—'}, end: ${value.end?.toString() ?? '—'} }` + ); + const showsTimeSegments = $derived(granularity !== 'day'); - type TraceEntry = { event: string; family: string; intent?: string; at: number }; - let trace = $state([]); - let stageRef = $state(null); + function valueFor(profile: ValueProfile): DateRange { + if (profile === 'date') return { start: startDate, end: endDate }; + if (profile === 'datetime') return { start: startDateTime, end: endDateTime }; + return { start: startZoned, end: endZoned }; + } + + function placeholderFor(profile: ValueProfile): DateValue { + if (profile === 'date') return startDate; + if (profile === 'datetime') return startDateTime; + return startZoned; + } + function setProfile(next: ValueProfile): void { + valueProfile = next; + if (next === 'date') granularity = 'day'; + if (next !== 'date' && granularity === 'day') granularity = 'minute'; + value = valueFor(next); + placeholder = placeholderFor(next); + } + + function setGranularity(next: Granularity): void { + granularity = next; + if (next === 'day' && valueProfile !== 'date') setProfile('date'); + if (next !== 'day' && valueProfile === 'date') setProfile('datetime'); + } + + function clearRange(): void { + value = { start: undefined, end: undefined }; + } + + function resetRange(): void { + value = valueFor(valueProfile); + } + + // ── Trace observer ─────────────────────────────────────────────────── $effect(() => { const el = stageRef; if (!el) return; @@ -60,47 +158,101 @@ return () => obs.disconnect(); }); + // ── Morfo introspection ────────────────────────────────────────────── const compiled = compileMorfo(dateRangeFieldMorfo); const partsList = $derived([...compiled.parts.byKebab.values()]); + const events = $derived([...compiled.actions.byName.values()]); + const rawMorfo = dateRangeFieldMorfo as unknown as { + name: string; + kebab: string; + scope: readonly string[]; + apg?: string; + parts: ReadonlyArray<{ + name: string; + kebab: string; + archetype?: string; + kind: string; + defaultElement?: string; + role?: string; + optional?: boolean; + states?: readonly string[]; + data?: ReadonlyArray<{ attr: string; values?: readonly string[]; severity?: string }>; + aria?: ReadonlyArray<{ + attr: string; + value?: { kind: string }; + condition?: { when: string; prop?: string; part?: string }; + severity?: string; + }>; + keyboard?: ReadonlyArray<{ key: string; action: string }>; + }>; + events?: ReadonlyArray<{ + name: string; + semantic: { + family: string; + verb?: string; + target?: unknown; + intent?: unknown; + sequence?: string; + }; + }>; + }; function fmtTime(at: number): string { const d = new Date(at); return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } - function fmtValue(v: DateRange): string { - const s = v.start?.toString() ?? '—'; - const e = v.end?.toString() ?? '—'; - return `${s} → ${e}`; + function valueLiteral(profile: ValueProfile, end: boolean): string { + if (profile === 'date') { + const d = end ? endDate : startDate; + return `new CalendarDate(${d.year}, ${d.month}, ${d.day})`; + } + if (profile === 'datetime') { + const d = end ? endDateTime : startDateTime; + return `new CalendarDateTime(${d.year}, ${d.month}, ${d.day}, ${d.hour}, ${d.minute}, ${d.second})`; + } + const d = end ? endZoned : startZoned; + return `new ZonedDateTime(${d.year}, ${d.month}, ${d.day}, '${d.timeZone}', ${d.offset}, ${d.hour}, ${d.minute}, ${d.second})`; } + // ── Snippets ───────────────────────────────────────────────────────── const somaSnippet = $derived( [ "
@@ -162,19 +333,41 @@

DateRangeField

Two-endpoint date entry, segment-by-segment. Mirrors - DateField × 2 with shared validation and a single - label naming the whole range. + DateField × 2 with shared validation + (minValue / maxValue / validate) + and a single Label naming the whole range. soma scopes a - DateField context per endpoint; eidos cascades size / variant - / color from the root. + DateFieldProvider per endpoint internally; eidos + overlays data-date-field on the root so the entire + date-field.css cascade (chrome, variant, size, + color) applies inside the range without duplicating recipe rules.

- value{fmtValue(value)} + parts{compiled.parts.order.length} + + + events{events.length} + + + profile{valueProfile} kind{kind} + + locale{locale} + + + + apgspinbutton + +
@@ -190,16 +383,25 @@ {disabled} {readonly} {required} + {startReadonlySegments} + {endReadonlySegments} + {granularity} + {hourCycle} + {hideTimeZone} + {locale} + {dir} + minValue={bounds ? minDate : undefined} + maxValue={bounds ? maxDate : undefined} > Period - + {#snippet children({ segments })} {#each segments as seg, i (i)} {seg.value} {/each} {/snippet} - + {#snippet children({ segments })} {#each segments as seg, i (i)} {seg.value} @@ -210,24 +412,35 @@
trace - {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} - {#if trace[0]} - · - last - {trace[0].event} ({trace[0].family}) @{fmtTime(trace[0].at)} + {#if trace.length === 0} + focus a segment and press arrows / digits to fire commits + {:else} + {#each trace.slice(0, 3) as entry} + + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + + {fmtTime(entry.at)} + {/each} {/if} + + value + {valueLabel} +
- + @@ -236,27 +449,171 @@ {#if tab === 'live'}

Controls

+

+ Every public prop wired. Switch the value profile, change + granularity, flip individual segments to readonly per + endpoint, swap locale / dir / hour-cycle — the live preview + re-renders and both snippets (soma + eidos) update. +

- soma props · value + behaviour + soma props · value shape
+ -
+ +
+ soma props · locale + direction +
+
+ + +
+ +
+ soma props · flags +
+
+ + + + + + -
+ +
+ soma props · per-endpoint readonly segments +
+
+ -
@@ -290,10 +647,16 @@
+
Actions
+
+ + +
+
soma - headless · two endpoint contexts + headless · DateFieldProvider scoped per endpoint svelte
{somaSnippet}
@@ -302,7 +665,7 @@
eidos - visual · cascading size / variant / color + visual · size + variant + color cascade svelte
{eidosSnippet}
@@ -313,47 +676,251 @@ {#if tab === 'api'}

API reference

-

- Inherits the full soma API (see - src/uix/soma/components/date-range-field/types.ts). - The eidos layer adds size / - variant / color with the same - vocabulary as <DateField>. -

+ +
DateRangeField (root)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultNotes
value somaDateRange—Bindable {`{ start, end }`}. Each endpoint is a DateValue.
placeholder somaDateValue—Drives the segment format for both endpoints.
onValueChange soma(v: DateRange) => void—Fired when both endpoints resolve to a valid range.
onStartValueChange soma(v: DateValue) => void—Per-endpoint hook.
onEndValueChange soma(v: DateValue) => void—Per-endpoint hook.
validate somaDateRangeValidator—Custom validator. Return a string error message to mark invalid.
onInvalid somaDateOnInvalid—Called when validation fails.
minValue somaDateValue—Earliest acceptable date for both endpoints.
maxValue somaDateValue—Latest acceptable date for both endpoints.
kind soma'date' | 'month' | 'year''date'Segment filter (Chakra-style). Both endpoints share the same kind.
granularity soma'day' | 'hour' | 'minute' | 'second'auto (from placeholder)Visual granularity. Adds time segments for hour+.
hourCycle soma12 | 24locale-derived12-hour or 24-hour clock.
hideTimeZone somabooleanfalseHide the timezone segment on ZonedDateTime.
locale somastringsoma configBCP 47 locale tag.
dir soma'ltr' | 'rtl'soma prefsText direction.
disabled somabooleanfalseDisables both inputs.
readonly somabooleanfalseSegments focusable, selection blocked.
required somabooleanfalseHidden form inputs are required.
startReadonlySegments somaEditableSegmentPart[][]Per-segment lock for the start endpoint.
endReadonlySegments somaEditableSegmentPart[][]Per-segment lock for the end endpoint.
errorMessageId somastring—External error element id (linked via aria-describedby).
variant eidos'surface' | 'outline' | 'ghost''surface'Mirrors DateField vocabulary.
color eidos'primary' | 'secondary' | 'neutral' | 'affirm' | 'risk' | 'threat''primary'Active-segment tint.
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl' (responsive)'md'Cascaded to both inputs + segments.
+
+ +
DateRangeField.Label
+
+ + + + + + +
NameTypeNotes
idstringAuto-generated. Used by both Inputs as aria-labelledby.
childrenSnippetLabel text.
+
+ +
DateRangeField.Input
+
+ + + + + + + + +
NameTypeDefaultNotes
type'start' | 'end'— requiredWhich endpoint this input drives.
namestring—Form field name for the hidden ISO-string input.
aria-labelstring—Prefer pairing with DateRangeField.Label; only used when label is absent.
aria-labelledbystring—External labelling element id.
+

+ Snippet args: {`{ segments: Array<{ part, value }> }`}. + Key the iteration by index (i) — seg.part + repeats for separator literals. +

+
+ +
DateRangeField.Segment
+
+ + + + + +
NameTypeNotes
partSegmentPartRe-export of DateField.Segment. Each Input scopes its own DateField context so segment resolution lands on the right endpoint.
+
+ +
Reference parity
+
+ + + + + + {#each refRows as row} + + + + + + + + + {/each} + +
LibraryDateRangeFieldkind filterper-endpoint readonly segsshared validatorauto-bridges to DateField
{row.lib}{row.dr}{row.kindFilter}{row.perEndpointReadonly}{row.sharedValidator}{row.autoBridge}
+
{/if} {#if tab === 'morfo'}

Morfo contract

+
- - {#each partsList as part} - {@const partAny = part as unknown as Record} + + + + + + + +
KebabRoleMarker
name{rawMorfo.name}
kebab{rawMorfo.kebab}
scope{rawMorfo.scope.join(', ')}
apg{rawMorfo.apg}
parts{rawMorfo.parts.length}
events{rawMorfo.events?.length ?? 0}
+
+ +
Parts overview
+
+ + + + + + {#each rawMorfo.parts as part} - - - + + + + + + + {/each}
KebabElementRoleArchetypeKindStatesOptional
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'}{part.kebab}{part.defaultElement ?? '—'}{part.role ?? '—'}{part.archetype ?? '—'}{part.kind}{part.states?.join(', ') ?? '—'}{part.optional ? '✓' : '—'}
+ + {#each rawMorfo.parts as part} + {#if (part.data && part.data.length > 0) || (part.aria && part.aria.length > 0) || (part.keyboard && part.keyboard.length > 0)} +
{part.name} ({part.kebab})
+ {#if part.data && part.data.length > 0} +
+ + + + {#each part.data as d} + + + + + + {/each} + +
data-attrvaluesseverity
{d.attr}{d.values?.join(' / ') ?? '—'}{d.severity ?? 'required'}
+
+ {/if} + {#if part.aria && part.aria.length > 0} +
+ + + + {#each part.aria as a} + + + + + + + {/each} + +
aria-attrvalue sourceconditionseverity
{a.attr}{a.value?.kind ?? '—'}{a.condition ? `${a.condition.when}:${a.condition.prop ?? a.condition.part ?? ''}` : '—'}{a.severity ?? 'required'}
+
+ {/if} + {#if part.keyboard && part.keyboard.length > 0} +
+ + + + {#each part.keyboard as k} + + + + + {/each} + +
keyaction
{k.key === ' ' ? 'Space' : k.key}{k.action}
+
+ {/if} + {/if} + {/each} + + {#if rawMorfo.events && rawMorfo.events.length > 0} +
Events
+
+ + + + + + {#each rawMorfo.events as ev} + + + + + + + {/each} + +
namefamilyverbsequence
{ev.name}{ev.semantic.family}{ev.semantic.verb ?? '—'}{ev.semantic.sequence ?? 'pre'}
+
+ {/if}
{/if} {#if tab === 'sema'}

- sema · events + sema · perceptual events

- Range-field morfo declares no events of its own. Perceptual - feedback comes from the two endpoint DateField runtimes - scoped inside each Input. + DateRangeField morfo declares {events.length} events of its own. + The range provider is passive at this contract layer — sema + signals come from the two endpoint DateField runtimes scoped + inside each Input. See the + DateField demo's + Sema tab for the full event vocabulary (segment commits, value + changes, validation signals).

+
+ + + + + + + + + + + + + + + + +
SourceEvents delegated
date-range-field morfo0 (passive)
date-field runtime · start endpointForwarded by soma's DateFieldProvider.create inside the start Input.
date-field runtime · end endpointForwarded by soma's DateFieldProvider.create inside the end Input.
+
{/if} @@ -361,26 +928,81 @@

Eidos recipe

- date-range-field.css wraps the label + the two - inputs with an em-dash separator rendered via - ::before on the end input. Cell visuals come - from date-field.css — each Input scopes its - own DateField context. + date-range-field.css is layout-only. Each + endpoint Input carries both + data-date-range-field-input AND + data-date-field-input on the same element (soma + overlays the markers). The eidos wrapper also overlays + data-date-field on the range root, so the entire + date-field.css cascade (chrome, variant, size, + color tokens) applies inside the range with zero duplication. + The range recipe contributes only: +

+
+ + + + + + + + + + +
SelectorOwnerPurpose
[data-date-range-field]eidos3-column grid: 1fr · auto · 1fr with label spanning the full first row.
[data-date-range-field-label]morfoLabel sits above both inputs, spans the entire grid row.
[data-date-range-field-input][data-endpoint='start']morfoPlaced in column 1, row 2.
[data-date-range-field-input][data-endpoint='end']morfoPlaced in column 3, row 2.
[data-date-range-field]:has(…)::beforeeidosEm-dash separator rendered in column 2, row 2 (only when both endpoints are present).
[data-date-range-field][data-disabled]eidosOpacity reduction + pointer-events lock.
+
+

+ All input chrome (border, padding, gap, focus ring, segment + active-tint, variant cascade) comes from + date-field.css — no recipe duplication.

{/if} {#if tab === 'a11y'}
-

A11y

+

A11y contract

- WAI-ARIA Spinbutton pattern per segment. Soma owns segment focus, arrow - stepping per endpoint, and paste distribution. + rel="noopener">Spinbutton — focusable, arrow-key + stepping, paste distribution. The two endpoints behave + identically; the only inter-endpoint behaviour is the + shared validation (range-level validate, + minValue, maxValue apply to the + resolved range).

+ +
Keyboard (per segment)
+
+ + + + + + + + + + + + +
KeyEffect
ArrowUpIncrement segment value (clamps to min/max).
ArrowDownDecrement segment value.
ArrowLeft / ArrowRightMove focus to previous / next segment within the active Input.
Tab / Shift+TabMove focus across endpoints (start → end and back).
typeaheadDirect numeric entry; auto-advances when the segment is full.
BackspaceClear current segment.
DeleteClear current segment.
pasteDistribute pasted ISO-string across the segments of the focused endpoint.
+
+ +
ARIA contract
+
+ + + + + + + + +
Partrolearia-*
Providergrouparia-labelledby → Label id when present
Label—Used as the accessible name for both Inputs.
Inputgrouparia-labelledby chains Label + endpoint role.
Segmentspinbuttonaria-valuenow, aria-valuemin, aria-valuemax, aria-label, aria-readonly when in {`{start,end}ReadonlySegments`}.
+
{/if}