feat(fields): time-* family composes real TimeFields via field.css (S6)

Applies the proven date-* pattern to the time family (exact structural parallel):

- time-field: chrome / label / invalid / disabled / focus come from field.css via
  the fieldRole identity (data-field / data-field-control); time-field.css keeps
  only segment specifics. No tokens aliasing the Field base. Imports field.css.
- time-range-field: COMPOSES two real <TimeField>s (soma TimeRangeField.Input →
  TimeField.Provider > TimeField.Input per endpoint) bound to the range slice;
  size/variant/color thread through a visual context; CSS is layout-only. (No
  syncPlaceholderToValue opt-out needed — TimeField has no calendar to auto-page,
  and the controlled placeholder keeps the endpoints from mutating each other.)
- TimePicker (single field): root carries data-field/data-time-field (A27);
  imports field.css.
- TimeRangePicker: inherits the composed endpoints; imports field.css (no
  double-box — its recipe never re-declared the endpoint box).

Baseline unchanged (59). Verified in browser: time-field paint + label, range
endpoints = composed TimeFields (nested provider>control, size threaded, value
renders), both pickers paint, no double-box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent b1098f362b
commit c4ea7f571f

@ -1,155 +1,63 @@
[data-time-field] {
--_time-field-height: var(--time-field-height-md);
--_time-field-padding-inline: var(--time-field-padding-inline-md);
/* time-field IS-A Field: the stack (display / flex-direction / gap / inline-size /
color / font-family), the control height / padding-inline, the value font-size, and
the FOCUS ring are ALL owned by field.css (`[data-field]`, `[data-field][data-size]`,
`[data-field-control]`, `[data-field-control]:focus-within`). time-field must NOT
re-declare tokens that alias them, so only its GENUINE specifics live here: the
inter-SEGMENT gap and the per-segment inline padding. */
--_time-field-gap: var(--time-field-gap-md);
--_time-field-segment-padding-inline: var(--time-field-segment-padding-inline-md);
--_time-field-font-size: var(--time-field-font-size-md);
--_time-field-accent-border: var(--color-primary-border);
--_time-field-accent-track: var(--color-primary-track);
--_time-field-accent-text: var(--color-primary-text);
display: inline-flex;
flex-direction: column;
gap: var(--time-field-stack-gap);
inline-size: 100%;
min-inline-size: 0;
color: var(--time-field-color);
font-family: var(--time-field-font-family);
font-size: var(--_time-field-font-size);
line-height: var(--time-field-line-height);
}
[data-time-field][data-size='xs'] {
--_time-field-height: var(--time-field-height-xs);
--_time-field-padding-inline: var(--time-field-padding-inline-xs);
--_time-field-gap: var(--time-field-gap-xs);
--_time-field-segment-padding-inline: var(--time-field-segment-padding-inline-xs);
--_time-field-font-size: var(--time-field-font-size-xs);
}
[data-time-field][data-size='sm'] {
--_time-field-height: var(--time-field-height-sm);
--_time-field-padding-inline: var(--time-field-padding-inline-sm);
--_time-field-gap: var(--time-field-gap-sm);
--_time-field-segment-padding-inline: var(--time-field-segment-padding-inline-sm);
--_time-field-font-size: var(--time-field-font-size-sm);
}
[data-time-field][data-size='lg'] {
--_time-field-height: var(--time-field-height-lg);
--_time-field-padding-inline: var(--time-field-padding-inline-lg);
--_time-field-gap: var(--time-field-gap-lg);
--_time-field-segment-padding-inline: var(--time-field-segment-padding-inline-lg);
--_time-field-font-size: var(--time-field-font-size-lg);
}
[data-time-field][data-size='xl'] {
--_time-field-height: var(--time-field-height-xl);
--_time-field-padding-inline: var(--time-field-padding-inline-xl);
--_time-field-gap: var(--time-field-gap-xl);
--_time-field-segment-padding-inline: var(--time-field-segment-padding-inline-xl);
--_time-field-font-size: var(--time-field-font-size-xl);
}
[data-time-field][data-color='secondary'] {
--_time-field-accent-border: var(--color-secondary-border);
--_time-field-accent-track: var(--color-secondary-track);
--_time-field-accent-text: var(--color-secondary-text);
}
[data-time-field][data-color='neutral'] {
--_time-field-accent-border: var(--color-neutral-border);
--_time-field-accent-track: var(--color-neutral-track);
--_time-field-accent-text: var(--color-neutral-text);
}
[data-time-field][data-color='affirm'] {
--_time-field-accent-border: var(--color-affirm-border);
--_time-field-accent-track: var(--color-affirm-track);
--_time-field-accent-text: var(--color-affirm-text);
}
/* The `data-color` accent apparatus was removed: its only reader was the segment
focus border, now aligned to Field's `[data-field-control]:focus-within` (a
color-agnostic focus ring), so per-color accent tokens have no consumer. */
[data-time-field][data-color='fulfill'] {
--_time-field-accent-border: var(--color-fulfill-border);
--_time-field-accent-track: var(--color-fulfill-track);
--_time-field-accent-text: var(--color-fulfill-text);
}
/* The label (color / step-down font-size / weight / invalid+disabled color) is owned
by field.css `[data-field-label]` — time-field's Label carries `data-field-label`
via `fieldRole('label')`, so re-declaring it here would only alias the Field base. */
[data-time-field][data-color='risk'] {
--_time-field-accent-border: var(--color-risk-border);
--_time-field-accent-track: var(--color-risk-track);
--_time-field-accent-text: var(--color-risk-text);
}
[data-time-field][data-color='threat'] {
--_time-field-accent-border: var(--color-threat-border);
--_time-field-accent-track: var(--color-threat-track);
--_time-field-accent-text: var(--color-threat-text);
}
[data-time-field][data-color='loss'] {
--_time-field-accent-border: var(--color-loss-border);
--_time-field-accent-track: var(--color-loss-track);
--_time-field-accent-text: var(--color-loss-text);
}
[data-time-field-label] {
color: var(--time-field-label-color);
font: inherit;
/* Label one typographic step below the input (don't inherit its size). The step
is `md − sm` (= 2px), tokenized; `1em` is the inherited input font. Scales. */
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm))); /* literal: one-step-down from context; the delta is token-derived (Field label doctrine) */
font-weight: var(--time-field-label-font-weight);
}
[data-time-field-input] {
display: inline-flex;
align-items: center;
/* Chrome (display / size / border / radius / bg / padding / font-weight) is owned by
`field.css [data-field-control]` — the segment container carries `data-field-control`
via `fieldRole('control')`. Only two GENUINE time-field specifics remain, both on the
compound `[data-time-field] [data-time-field-input]` (0,2,0) so they out-rank field.css's
`[data-field-control]` (0,1,0) regardless of bundle load order: the inter-SEGMENT gap,
and the `transition` OVERRIDE — `border-color` must be INSTANT (field.css transitions it,
but a keyboard increment briefly blurs/refocuses the segment, so a transitioned border
flickers every step; the focus ring is an `outline`, instant). */
[data-time-field] [data-time-field-input] {
gap: var(--_time-field-gap);
min-inline-size: 0;
inline-size: 100%;
min-block-size: var(--_time-field-height);
padding-inline: var(--_time-field-padding-inline);
border: var(--time-field-border-width) solid var(--time-field-border);
border-radius: var(--time-field-radius);
background: var(--time-field-bg);
color: inherit;
font-weight: var(--font-weight-medium);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would
fade out/in on every step (visible flicker). The focus ring is an
`outline` (instant). `box-shadow` is no longer used here (post focus-ring
migration) — dropped from the transition. */
transition: background var(--time-field-transition-duration) var(--time-field-transition-ease);
}
[data-time-field][data-variant='outline'] [data-time-field-input] {
background: transparent;
}
[data-time-field][data-variant='ghost'] [data-time-field-input] {
border-color: transparent;
background: transparent;
padding-inline: 0;
}
[data-time-field-input]:has([data-time-field-segment]:focus-visible) {
border-color: var(--_time-field-accent-border);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* Variants (surface / outline / ghost) are owned by field.css
(`[data-field][data-variant]` retargets `--_field-control-bg` / `--_field-control-border`)
— time-field passes `data-variant` on the `[data-field]` root, so no re-declaration. */
[data-time-field][data-invalid] [data-time-field-input],
[data-time-field-input][data-invalid] {
border-color: var(--time-field-border-invalid);
}
[data-time-field][data-disabled],
[data-time-field-input][data-disabled] {
opacity: var(--time-field-disabled-opacity);
}
/* Focus ring (border-color-focus + outline), invalid (border-color) and disabled
(opacity) on the control are owned by field.css `[data-field-control]:focus-within`
/ `[data-field][data-invalid] [data-field-control]` / `[data-field][data-disabled]`
— the segment container carries `data-field-control`, so time-field must not alias them. */
[data-time-field-segment] {
display: inline-flex;

@ -1,4 +1,8 @@
<script lang="ts">
// time-field IS-A Field: field.css owns the chrome / label / invalid / disabled /
// focus (the `[data-field*]` parts time-field adopts via `fieldRole()`).
// time-field.css keeps ONLY segment specifics — NO tokens aliasing the Field base.
import '../field/field.css';
import './time-field.css';
import { ActiveEidos } from '$uix/eidos';
import * as TimeField from '$soma/components/time-field';

@ -1,7 +1,10 @@
<script lang="ts">
// The segmented input reuses the TimeField recipe; the sliders use soma
// Slider parts (not eidos <Slider>). Load both recipes here before
// time-picker.css so it can layer on top.
// time-picker.css so it can layer on top. The segmented input is a composed
// TimeField (TimePicker.Input = TimeField.Input); `time-field.css` does NOT pull
// `field.css` (only time-field.svelte does), so field.css MUST be imported here.
import '../field/field.css';
import '$uix/eidos/components/time-field/time-field.css';
import '$uix/eidos/components/slider/slider.css';
import './time-picker.css';
@ -38,12 +41,20 @@
setPickerShellVisual({ getSize: () => resolvedSize });
</script>
<!--
Overlay the Field-family identity: the segmented input carries `data-field-control`
(soma composes TimeField.Input → `fieldRole('control')`), so `data-field` on the
root runs field.css's `--_field-control-*` cascade and paints it; `data-time-field`
applies time-field.css's segment specifics (gap + `[data-time-field-segment]`).
-->
<TimePicker.Provider
{...rest}
{granularity}
bind:value
bind:placeholder
bind:open
data-field=""
data-time-field=""
data-size={resolvedSize}
data-variant={variant}
data-color={color}

@ -0,0 +1,29 @@
import { getContext, setContext } from 'svelte';
import type { TimeRangeFieldColor, TimeRangeFieldSize, TimeRangeFieldVariant } from './types';
const TIME_RANGE_FIELD_VISUAL_CONTEXT = Symbol('uix.eidos.time-range-field.visual');
/**
* Visual context published by the eidos `<TimeRangeField>` root so each endpoint
* `<TimeRangeField.Input>` can theme its composed `<TimeField>` with the range's
* size / variant / color. Mirrors `setDateRangeFieldVisualContext` (A27): the
* composite owns the visual axes and threads them to the real base fields it
* renders — no `data-*` cascade off a borrowed root marker.
*/
export interface TimeRangeFieldVisualContext {
/** Already resolved to a concrete size (the root ran `eidos.resolve`). */
getSize(): TimeRangeFieldSize;
getVariant(): TimeRangeFieldVariant;
getColor(): TimeRangeFieldColor;
}
export function setTimeRangeFieldVisualContext(
ctx: TimeRangeFieldVisualContext
): TimeRangeFieldVisualContext {
setContext(TIME_RANGE_FIELD_VISUAL_CONTEXT, ctx);
return ctx;
}
export function getTimeRangeFieldVisualContext(): TimeRangeFieldVisualContext | undefined {
return getContext<TimeRangeFieldVisualContext | undefined>(TIME_RANGE_FIELD_VISUAL_CONTEXT);
}

@ -1,17 +1,26 @@
<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.
* Eidos `<TimeRangeField.Input>`. Reads the range's visual context (size /
* variant / color) and threads it to soma's Input as `data-*` attrs — soma
* puts them on the endpoint's composed TimeField root (`[data-field]`), so each
* endpoint self-themes exactly like a standalone `<TimeField>`. Snippet args
* (`segments`) come from soma unchanged.
*/
import * as TimeRangeField from '$soma/components/time-range-field';
import { getTimeRangeFieldVisualContext } from './context.svelte';
import type { TimeRangeFieldInputProps } from './types';
let { children: bodyContent, ...rest }: TimeRangeFieldInputProps = $props();
const visual = getTimeRangeFieldVisualContext();
</script>
<TimeRangeField.Input {...rest}>
<TimeRangeField.Input
{...rest}
data-size={visual?.getSize()}
data-variant={visual?.getVariant()}
data-color={visual?.getColor()}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}

@ -1,67 +1,44 @@
/*
* TimeRangeField recipe — layout only.
* TimeRangeField — LAYOUT ONLY.
*
* The chrome of each endpoint input is painted by `time-field.css`
* because:
* Each endpoint is a REAL composed `<TimeField>` (soma renders
* `TimeField.Provider > TimeField.Input`), so all endpoint chrome — box, border,
* radius, padding, focus ring, invalid, disabled, segment styling — comes from
* `field.css` / `time-field.css` on the endpoint's own `[data-field]` /
* `[data-time-field-input]`. This recipe NEVER paints chrome; it only places the
* two endpoints + the shared label + the separator on a grid.
*
* 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)
* [data-time-range-field] → grid wrapper (label row + inputs row)
* [data-time-range-field-label] → shared label, spans the full row
* [data-time-range-field-input] → endpoint = the composed TimeField root
* (also [data-field] / [data-time-field])
*/
/*
* 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] {
[data-time-range-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));
row-gap: var(--space-1-5);
align-items: center;
inline-size: 100%;
min-inline-size: 0;
}
/* Shared label — one step below the control, matching the Field label doctrine.
The range root is NOT a `[data-field]`, so it can't read field.css's
`[data-field]`-scoped label tokens; it uses the same foundation values. */
[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));
color: var(--color-content-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
}
[data-time-range-field-input] {
grid-row: 2;
min-inline-size: 0;
font-weight: var(--font-weight-medium);
}
[data-time-range-field-input][data-endpoint='start'] {
@ -72,8 +49,8 @@
grid-column: 3;
}
/* Em-dash separator between the two inputs. Anchored to the grid's
* middle column / second row. */
/* Em-dash separator between the two inputs, anchored to the grid's middle
column / second row. Only when both endpoints are present. */
[data-time-range-field]:has(
[data-time-range-field-input][data-endpoint='start']
):has([data-time-range-field-input][data-endpoint='end'])::before {
@ -81,13 +58,19 @@
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(--style-label-font-family));
font-size: 0.9em; /* literal: separator scales with the field font-size */
user-select: none;
pointer-events: none;
}
/* Range-level disabled: each endpoint TimeField already dims its own control
(field.css `[data-field][data-disabled]`); here we only lock interaction and
dim the shared label + separator. */
[data-time-range-field][data-disabled] {
opacity: var(--time-field-disabled-opacity, 0.55);
pointer-events: none;
}
[data-time-range-field][data-disabled] [data-time-range-field-label],
[data-time-range-field][data-disabled]::before {
opacity: var(--opacity-disabled);
}

@ -1,15 +1,23 @@
<script lang="ts">
// Renders time-field markup without mounting eidos <TimeField> — load its recipe first.
// TimeRangeField COMPOSES two real TimeFields (one per endpoint) — it does not
// replicate time-field's markup/CSS. `field.css` owns the endpoint chrome and
// `time-field.css` the segment specifics; import both here so the composed
// endpoints (rendered by soma's Input, not the eidos <TimeField> wrapper) are
// painted. NOTE: `time-field.css` does NOT pull `field.css` (only time-field.svelte
// does, via its JS import), so field.css MUST be imported explicitly first.
import '../field/field.css';
import '$uix/eidos/components/time-field/time-field.css';
import './time-range-field.css';
/**
* 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.
* Eidos `<TimeRangeField>` — root. A layout coordinator: it owns the shared range
* value + the visual axes (size / variant / color) and publishes them via
* `setTimeRangeFieldVisualContext` so each endpoint `<TimeRangeField.Input>` can
* theme its composed `<TimeField>`. The morfo emits `data-time-range-field` on the
* container; `time-range-field.css` is layout-only (grid + separator + label).
*/
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangeField from '$soma/components/time-range-field';
import { setTimeRangeFieldVisualContext } from './context.svelte';
import type { TimeRangeFieldProps } from './types';
let {
@ -24,28 +32,16 @@
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// A27 visual context — threaded to each endpoint's composed TimeField, instead
// of a `data-*` cascade off a borrowed `data-time-field` root marker.
setTimeRangeFieldVisualContext({
getSize: () => resolvedSize,
getVariant: () => variant,
getColor: () => color
});
</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}
>
<TimeRangeField.Provider {...rest} bind:value bind:placeholder>
{@render children?.()}
</TimeRangeField.Provider>

@ -3,6 +3,10 @@
import '$uix/eidos/components/slider/slider.css';
// Renders time-field (segments) + time-range-field parts via soma, not their
// eidos Providers — so load their recipes here, before time-range-picker.css.
// The range endpoints compose real <TimeField>s (soma TimeRangeField.Input →
// TimeField.Provider); `time-field.css` does NOT pull `field.css`, so it must be
// imported explicitly for the endpoint chrome.
import '../field/field.css';
import '$uix/eidos/components/time-field/time-field.css';
import '$uix/eidos/components/time-range-field/time-range-field.css';
import './time-range-picker.css';

@ -1,5 +1,6 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { fieldRole } from '../field-role';
export const timeFieldMorfo = {
name: 'TimeField',
@ -33,6 +34,8 @@ export const timeFieldMorfo = {
defaultElement: 'div',
optional: false,
data: [
// time-field IS-A Field (adopts the family root identity), then evolves.
fieldRole('provider'),
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
@ -49,6 +52,8 @@ export const timeFieldMorfo = {
role: 'group',
optional: false,
data: [
// The segment container IS the Field control → field.css owns its chrome.
fieldRole('control'),
{ attr: 'data-invalid', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
@ -153,6 +158,7 @@ export const timeFieldMorfo = {
defaultElement: 'label',
optional: true,
data: [
fieldRole('label'),
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],

@ -1,15 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import {
TimeFieldProvider,
TimeFieldInputProvider
} from '../../time-field/time-field-provider.svelte';
import * as TimeField from '../../time-field';
import {
TimeRangeFieldProvider,
timeRangeFieldAttrs
@ -25,89 +17,70 @@
name = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
children: bodyContent,
child,
...restProps
}: TimeRangeFieldInputProps = $props();
const provider = TimeRangeFieldProvider.require();
// A30: `id` is generated once by `createId` and `type` is a stable prop —
// one-shot registration at component setup. `untrack` is the right
// shape here: it reads current values without subscribing, silences
// Svelte's `state_referenced_locally` warning (which would otherwise
// push us back to an unnecessary `$effect` child→parent edge), and
// makes the "read once" intent explicit.
// A30: one-shot id registration at setup (no `$effect` loop).
untrack(() => {
if (type === 'start') provider.startInputId.current = id;
else provider.endInputId.current = id;
});
// Bridge this endpoint's value into the shared range state. Each Input
// creates its own TimeFieldProvider context local to the Input's subtree.
// This endpoint's slice of the shared range value + its per-endpoint locks.
const endpointValue = $derived(
type === 'start' ? provider.opts.startValue : provider.opts.endValue
);
const endpointReadonlySegments = $derived(
type === 'start'
? provider.opts.startReadonlySegments.current
: provider.opts.endReadonlySegments.current
);
// Create the TimeField context that drives segment computation for this
// endpoint. Two Inputs under the same TimeRangeField each set their own
// context in their own component subtree.
TimeFieldProvider.create({
id: readableActive(() => `${id}-field`),
value: writableActive(
() => endpointValue.current,
(v) => {
endpointValue.current = v;
}
),
placeholder: provider.opts.placeholder,
validate: readableActive(() => undefined), // Range-level validation lives on the parent
onInvalid: readableActive(() => undefined),
// Range endpoints surface commits through the parent range provider (N2).
onValueCommit: readableActive(() => undefined),
minValue: provider.opts.minValue,
maxValue: provider.opts.maxValue,
disabled: readableActive(() => provider.isDisabled),
readonly: readableActive(() => provider.isReadonly),
readonlySegments: readableActive(() =>
type === 'start'
? provider.opts.startReadonlySegments.current
: provider.opts.endReadonlySegments.current
),
required: readableActive(() => provider.isRequired),
granularity: provider.opts.granularity,
hideTimeZone: provider.opts.hideTimeZone,
hourCycle: provider.opts.hourCycle,
locale: readableActive(() => provider.opts.locale.current ?? ''),
dir: provider.opts.dir,
errorMessageId: provider.opts.errorMessageId
});
const state = TimeFieldInputProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
name: readableActive(() => name),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledBy: readableActive(() => ariaLabelledBy)
// Endpoint identity + the eidos visual attrs threaded through `restProps`
// (data-size / data-variant / data-color) land on the composed TimeField root.
const identity = $derived({
[timeRangeFieldAttrs.input]: '',
'data-endpoint': type
});
// Overlay the TimeRangeField's endpoint marker on top of TimeField's own
// `data-time-field-input` attribute.
const mergedProps = $derived(
mergeProps(restProps, state.props, {
[timeRangeFieldAttrs.input]: '',
'data-endpoint': type
} as Record<string, unknown>)
);
</script>
{#if child}
{@render child({ props: mergedProps, segments: state.segments })}
{:else}
<div {...mergedProps}>
{@render children?.({ segments: state.segments })}
</div>
{/if}
<!--
Each endpoint COMPOSES a real <TimeField> (provider + input) bound to its slice of
the shared range value — it does not re-implement time-field markup. Range-level
concerns stay on the range provider: `validate` / `onInvalid` / `onValueCommit` are
omitted (the range owns them). TimeField has no calendar to auto-page, so the shared
placeholder needs no `syncPlaceholderToValue` opt-out; passing it controlled keeps
each endpoint from mutating the other's anchor.
-->
<TimeField.Provider
{...(restProps as Record<string, unknown>)}
id={`${id}-field`}
value={endpointValue.current}
onValueChange={(v) => {
endpointValue.current = v;
}}
placeholder={provider.opts.placeholder.current}
minValue={provider.opts.minValue.current}
maxValue={provider.opts.maxValue.current}
disabled={provider.isDisabled}
readonly={provider.isReadonly}
readonlySegments={endpointReadonlySegments}
required={provider.isRequired}
granularity={provider.opts.granularity.current}
hideTimeZone={provider.opts.hideTimeZone.current}
hourCycle={provider.opts.hourCycle.current}
locale={provider.opts.locale.current ?? ''}
dir={provider.opts.dir.current}
errorMessageId={provider.opts.errorMessageId.current}
{...(identity as Record<string, unknown>)}
>
<TimeField.Input {id} bind:ref {name} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} {child}>
{#snippet children({ segments })}
{@render bodyContent?.({ segments })}
{/snippet}
</TimeField.Input>
</TimeField.Provider>

Loading…
Cancel
Save

Powered by TurnKey Linux.