diff --git a/src/uix/eidos/components/date-range-field/README.md b/src/uix/eidos/components/date-range-field/README.md new file mode 100644 index 000000000..427747aaf --- /dev/null +++ b/src/uix/eidos/components/date-range-field/README.md @@ -0,0 +1,122 @@ +# `` — eidos + +Two-endpoint date entry, segment-by-segment. Mirrors +`` × 2 with shared validation (min/max/`validate`) +and a single label that names the whole range. + +## Usage + +```svelte + + + + Period + + {#snippet children({ segments })} + {#each segments as seg} + {seg.value} + {/each} + {/snippet} + + + {#snippet children({ segments })} + {#each segments as seg} + {seg.value} + {/each} + {/snippet} + + +``` + +## Parts + +| Part | Notes | +|---|---| +| `DateRangeField` (root) | Provider; bind:value `DateRange`. | +| `Label` | Single label naming the whole range. | +| `Input` | One per endpoint (`type='start'` / `type='end'`). Each scopes its own DateField context internally. | +| `Segment` | Re-export of `DateField.Segment` — same recipe. | + +## Eidos-layer props + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `variant` | `'surface' \| 'outline' \| 'ghost'` | `'surface'` | Inherits DateField vocabulary. | +| `color` | `primary \| secondary \| neutral \| affirm \| risk \| threat` | `'primary'` | Active endpoint tint. | +| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` (responsive) | `'md'` | Cascaded to both inputs + segments. | + +## Headless props (soma) + +`value`, `placeholder`, `onValueChange`, `onStartValueChange`, +`onEndValueChange`, `validate`, `onInvalid`, `minValue`, `maxValue`, +`disabled`, `readonly`, `startReadonlySegments`, +`endReadonlySegments`, `required`, `kind`, `granularity`, +`hideTimeZone`, `hourCycle`, `locale`, `dir`, `errorMessageId`. +See `src/uix/soma/components/date-range-field/types.ts`. + +## Passive justification + +Passive **at the morfo level** — the range-field morfo declares +parts (Provider, Label, Input) but no `events`. Sema events +(segment commits, value changes) are emitted by the two composed +DateField runtimes scoped per endpoint, where they belong; the +range provider itself owns no event vocabulary beyond what soma +synthesizes from its endpoint listeners. + +The component IS interactive from the user's perspective — +"passive" here is the same contract-layer classification used for +`` and ``. + +## Baseline + +WAI-ARIA Spinbutton pattern (per segment): + + +Soma owns: segment focus / arrow stepping per endpoint, paste +distribution, validation, hidden form inputs (ISO date strings). + +## Comparativa + +| Lib | Two endpoints | Shared validate | Segment scoping | Range tint | +|---|---|---|---|---| +| **react-aria DateRangePicker** | ✓ | ✓ | per endpoint | — | +| **Bits UI DateRangeField** | ✓ | ✓ | per endpoint | — | +| **Ark UI** | ✓ | ✓ | per endpoint | — | +| **Eidos (this)** | ✓ | ✓ | per endpoint | ✓ active endpoint border | + +Functional parity with the strongest references; range-tint affordance +keeps focus visible across two-input layouts. + +## Decisiones + +- **Segment re-uses DateField's wrapper** — each endpoint Input + scopes a DateField context, so the existing date-field segment + recipe applies without duplication. +- **Separator rendered via `::before` pseudo**, not as a `` + child. Consumers don't have to ship `—` between + Inputs; the recipe handles it via `:has([data-type='end'])`. +- **No `Description` / `Hint` parts.** This is the *field* primitive; + description belongs to the enclosing `` shell. Composers + wrap with `` for the full form pattern. + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| Hidden form inputs styling | **diferir** | Soma emits two ISO hidden inputs; not visible by recipe. Demo doesn't expose. | +| `data-color='risk'` cascade for invalid endpoints | **implementar** próximo turno | Soma exposes `invalid` at provider level; would benefit from per-endpoint coloring. | +| RTL separator glyph swap | **diferir** | Currently uses `—` for all locales; some RTL locales prefer `إلى` etc. Wait for actual i18n need. | + +## Reference + +- Bits UI: +- Ark UI: +- react-aria-components: + diff --git a/src/uix/eidos/components/date-range-field/date-range-field-input.svelte b/src/uix/eidos/components/date-range-field/date-range-field-input.svelte new file mode 100644 index 000000000..a87af087a --- /dev/null +++ b/src/uix/eidos/components/date-range-field/date-range-field-input.svelte @@ -0,0 +1,18 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/date-range-field/date-range-field-label.svelte b/src/uix/eidos/components/date-range-field/date-range-field-label.svelte new file mode 100644 index 000000000..cf895963b --- /dev/null +++ b/src/uix/eidos/components/date-range-field/date-range-field-label.svelte @@ -0,0 +1,8 @@ + + +{@render children?.()} 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 new file mode 100644 index 000000000..e703df436 --- /dev/null +++ b/src/uix/eidos/components/date-range-field/date-range-field.css @@ -0,0 +1,89 @@ +/* + * DateRangeField recipe. + * + * [data-date-range-field] → label + control stack + * [data-date-range-field-label] → optional label + * [data-date-range-field-input] → endpoint container (start or end) + * + * Each Input wraps a DateField context internally, so segments inside + * pick up date-field's existing recipe automatically. The range + * recipe only adds: + * + * - 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. + */ + +[data-date-range-field] { + display: inline-flex; + flex-direction: column; + gap: var(--date-field-stack-gap, var(--space-1-5)); + inline-size: 100%; + min-inline-size: 0; + font-family: var(--date-field-font-family, var(--style-label-font-family)); +} + +[data-date-range-field-label] { + 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; + 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]:has([data-date-range-field-input][data-type='start']) + :where([data-date-range-field-input][data-type='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%); + color: var(--date-range-field-separator-color, var(--color-content-muted)); + 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; +} + +[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 new file mode 100644 index 000000000..55acc8c55 --- /dev/null +++ b/src/uix/eidos/components/date-range-field/date-range-field.svelte @@ -0,0 +1,35 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/date-range-field/index.ts b/src/uix/eidos/components/date-range-field/index.ts new file mode 100644 index 000000000..1cbfb4c2d --- /dev/null +++ b/src/uix/eidos/components/date-range-field/index.ts @@ -0,0 +1,56 @@ +// DateRangeField — eidos compound API. +// +// import { DateRangeField } from '$uix/eidos/components/date-range-field'; +// +// +// Period +// +// {#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 DateField's eidos wrapper — each endpoint +// Input scopes its own DateField context so segment resolution still +// targets the right endpoint. +import DateRangeFieldComponent from './date-range-field.svelte'; +import Input from './date-range-field-input.svelte'; +import Label from './date-range-field-label.svelte'; +import DateField from '$uix/eidos/components/date-field'; + +const Segment = DateField.Segment; + +type DateRangeFieldNamespace = typeof DateRangeFieldComponent & { + Label: typeof Label; + Input: typeof Input; + Segment: typeof Segment; +}; + +const DateRangeField = DateRangeFieldComponent as DateRangeFieldNamespace; +DateRangeField.Label = Label; +DateRangeField.Input = Input; +DateRangeField.Segment = Segment; + +export { DateRangeField }; +export default DateRangeField; + +export type { + DateRangeFieldProps, + DateRangeFieldLabelProps as LabelProps, + DateRangeFieldInputProps as InputProps, + DateRangeFieldSegmentProps as SegmentProps, + DateRangeFieldSize, + DateRangeFieldVariant, + DateRangeFieldColor, + InputSnippetProps +} from './types'; diff --git a/src/uix/eidos/components/date-range-field/types.ts b/src/uix/eidos/components/date-range-field/types.ts new file mode 100644 index 000000000..85b6cd7fc --- /dev/null +++ b/src/uix/eidos/components/date-range-field/types.ts @@ -0,0 +1,58 @@ +import type { + ProviderProps, + LabelProps, + InputProps, + InputSnippetProps, + SegmentProps +} from '$soma/components/date-range-field'; +import type { ResponsiveProp } from '$uix/eidos/lib/types'; +import type { DateFieldSize, DateFieldVariant, DateFieldColor } from '$uix/eidos/components/date-field'; + +/** + * Eidos `` — visual wrapper over the headless + * date-range-field. Two endpoint inputs (`type='start'` / + * `type='end'`), one shared Label, and the same Segment used by + * date-field for each editable slot. + * + * + * Period + * + * {#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 DateField vocabulary (size / variant / + * color) — the start and end Inputs read the cascaded data-attrs from + * the root, identical to how Field renders its Control. + */ + +export type DateRangeFieldSize = DateFieldSize; +export type DateRangeFieldVariant = DateFieldVariant; +export type DateRangeFieldColor = DateFieldColor; + +export type DateRangeFieldProps = ProviderProps & { + /** Visual treatment. Mirrors DateField. @default 'surface' */ + variant?: DateRangeFieldVariant; + /** Color tone. @default 'primary' */ + color?: DateRangeFieldColor; + /** Sizing scale. @default 'md' */ + size?: ResponsiveProp; +}; + +export type DateRangeFieldLabelProps = LabelProps; +export type DateRangeFieldInputProps = InputProps; +export type DateRangeFieldSegmentProps = SegmentProps; + +export type { InputSnippetProps }; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index e5ba34a35..24ed9e2c9 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -94,6 +94,7 @@ @import './components/dropdown-menu/dropdown-menu.css'; @import './components/context-menu/context-menu.css'; @import './components/pin-input/pin-input.css'; +@import './components/date-range-field/date-range-field.css'; @import './components/drawer/drawer.css'; @import './components/field/field.css'; @import './components/form/form.css'; diff --git a/src/uix/morfo/components/date-range-field.ts b/src/uix/morfo/components/date-range-field.ts index 4ebb853e0..5e39c2c6f 100644 --- a/src/uix/morfo/components/date-range-field.ts +++ b/src/uix/morfo/components/date-range-field.ts @@ -4,7 +4,11 @@ import { v } from '../types'; export const dateRangeFieldMorfo = { name: 'DateRangeField', kebab: 'date-range-field', - scope: ['soma'], + // Eidos wrapper added 2026-05-23 (`src/uix/eidos/components/date-range-field/`). + // Sema events forwarded by the composed DateField instances (one per + // endpoint) — no per-component cascade needed at this layer. + scope: ['soma', 'sema', 'eidos'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/', texts: { label: '#?components.date-range-field.label|Date Range Field', start: '#?components.date-range-field.start|Start date', diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 513255852..359a0e763 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -152,6 +152,7 @@ { slug: '/uix/components/month-grid', label: 'Month grid' }, { slug: '/uix/components/year-grid', label: 'Year grid' }, { slug: '/uix/components/date-field', label: 'Date field' }, + { slug: '/uix/components/date-range-field', label: 'Date range field' }, { 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' }, diff --git a/web/routes/uix/components/date-range-field/+page.svelte b/web/routes/uix/components/date-range-field/+page.svelte new file mode 100644 index 000000000..769060b25 --- /dev/null +++ b/web/routes/uix/components/date-range-field/+page.svelte @@ -0,0 +1,386 @@ + + +
+
+
Form · DateRangeField
+

DateRangeField

+

+ Two-endpoint date entry, segment-by-segment. Mirrors + DateField × 2 with shared validation and a single + label naming the whole range. + soma scopes a + DateField context per endpoint; eidos cascades size / variant + / color from the root. +

+
+ + value{fmtValue(value)} + + + kind{kind} + +
+
+ +
+
+ + Period + + {#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 + {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} +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+ +
+ soma props · value + behaviour +
+
+ + + + +
+ +
+ eidos props · chrome +
+
+ + + +
+ +
+
+ soma + headless · two endpoint contexts + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · cascading size / variant / color + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#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>. +

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

Morfo contract

+
+ + + + {#each partsList as part} + {@const partAny = part as unknown as Record} + + + + + + {/each} + +
KebabRoleMarker
{partAny.kebab}{partAny.role ?? '—'}{partAny.marker ?? '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ Range-field morfo declares no events of its own. Perceptual + feedback comes from the two endpoint DateField runtimes + scoped inside each Input. +

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

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. +

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

A11y

+

+ WAI-ARIA Spinbutton pattern per segment. Soma owns segment focus, arrow + stepping per endpoint, and paste distribution. +

+
+ {/if} +