feat(time-range-picker): eidos wrapper + canonical picker-shell parity

Builds the Eidos time-range-picker layer end-to-end and brings color-picker's
morfo into parity with the other pickers.

time-range-picker
- soma: `mode` prop, `pickerShellContext` registration with
  commit/cancel/clear and `valueOnOpen` snapshot for revert; Provider now
  renders a div with data-state/disabled/readonly/required + aria mirrors.
- soma: new `Clock` part (mirrors TimePicker.Clock with role="dialog" and
  data-state).
- morfo: scope ['soma','sema'] + apg dialog-modal; promoted Provider to
  public div with states/data-state; added Clock part; declared the 6
  canonical picker events (open / close-commit / close-cancel /
  close-dismiss / close-dismiss-outside / commit-clear); slider parts
  declare data-endpoint + data-disabled.
- eidos: full namespace (Trigger / Content / Clock / HourSlider /
  MinuteSlider / SecondSlider / DayPeriodToggle + re-exported PickerShell
  Footer / Clear / Cancel / Close). Sliders compose generic SliderProvider
  (N-8) and ship with optional rulers above hour / below minute+second.
- eidos css: 2-column endpoint grid in the clock; per-endpoint range
  tint; AM/PM toggle styling; reuses time-field tokens.
- demo: interactive testbed at /uix/components/time-range-picker mirroring
  the time-picker demo — every soma + eidos prop has a live control,
  closeOnRangeComplete + start/endReadonlySegments toggles, footer
  composition switches, sema play buttons, soma+eidos snippets.
- nav: added entry between Time picker and Color field.

color-picker (audit fixes)
- morfo: Provider declares states ['open','closed'] + data-state attr;
  added the same 6 canonical picker events targeting the `content` part
  (handle-pick / handle-drag / commit-set preserved).
- soma: runtime now feeds open state + disabled/readonly/required/invalid
  props to the morfo runtime so data-state actually emits.

Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4524 files.

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

@ -0,0 +1,98 @@
# Eidos TimeRangePicker
Composite popover combining a segmented `TimeRangeField` input pair (start + end)
and a `Clock` view with per-endpoint sliders (Hour / Minute / Second + AM/PM
toggle). Soma owns range state coordination (`{ start, end }` + open +
helpers `commit / cancel / clear`); Eidos adds visual treatment, the popover
content shell, the side-by-side endpoint layout and the canonical Footer
parts.
## Baseline
Air no tenía un `TimeRangePicker` propio en la rama anterior. La referencia
real es la composición Soma `time-range-picker` + `time-range-field` +
`popover` + `slider` que ya implementa la edición segmentada de ambos
extremos y la selección por sliders independientes (`type="start" |
"end"`). La receta Eidos sólo envuelve esa superficie con tamaños, variante
visual y paleta de intents. Referencias externas: React Aria
DateRangePicker (pares), Ark UI Time Picker (single), Chakra Time Picker.
## Superficie
```svelte
<TimeRangePicker bind:value bind:open mode="inline">
<TimeRangePicker.Input type="start">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Input type="end">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
<TimeRangePicker.Trigger />
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Portal>
<TimeRangePicker.Content>
<TimeRangePicker.Clock>
<div class="time-range-picker-endpoint">
<span class="time-range-picker-endpoint-label">Start</span>
<TimeRangePicker.HourSlider type="start" />
<TimeRangePicker.MinuteSlider type="start" />
<TimeRangePicker.DayPeriodToggle type="start" />
</div>
<div class="time-range-picker-endpoint">
<span class="time-range-picker-endpoint-label">End</span>
<TimeRangePicker.HourSlider type="end" />
<TimeRangePicker.MinuteSlider type="end" />
<TimeRangePicker.DayPeriodToggle type="end" />
</div>
<TimeRangePicker.Footer>
<TimeRangePicker.Clear />
<TimeRangePicker.Cancel />
<TimeRangePicker.Close />
</TimeRangePicker.Footer>
</TimeRangePicker.Clock>
</TimeRangePicker.Content>
</TimeRangePicker.Portal>
</TimeRangePicker>
```
Partes públicas: `Trigger`, `Content`, `Clock`, `Label`, `Input`,
`Segment`, `HourSlider`, `MinuteSlider`, `SecondSlider`,
`DayPeriodToggle`, `Footer`, `Clear`, `Cancel`, `Close`. Cada slider /
toggle acepta `type: 'start' | 'end'`.
## Comparativa
| Característica | React Aria | Ark | Chakra | UIX |
| ----------------------------- | ---------- | --- | ------ | --- |
| Inputs segmentados (par) | sí | no | no | sí |
| Sliders por endpoint | no | no | no | sí |
| Modo inline / modal | no | no | no | sí |
| Footer (composición) | no | no | no | sí |
| commit / cancel / clear shell | manual | no | no | sí |
| Range state-machine swap | sí | n/a | n/a | sí |
## Decisiones
- **State machine empty → pending → complete** vive en el provider Soma —
el eidos no decide cuándo se cierra. `closeOnRangeComplete` es opcional.
- **`mode='inline'|'modal'`** propaga al popover (`modal: mode === 'modal'`).
- **Footer = composición** — sin `*Button` props, igual que TimePicker.
- **N-8** — los sliders comparten `SliderProvider` (no se reimplementa
drag / teclado).
- **Layout 2 columnas** — Start y End ocupan columnas independientes en
el popover. Por debajo de 40rem caen apiladas.
## Gaps
- No hay vista "preset list" (Hoy, Última hora…). Se puede componer fuera.
- No hay swap automático visible si el usuario fija `end < start` — la
validación queda en manos del `validate` opt.

@ -0,0 +1,32 @@
import { getContext, setContext } from 'svelte';
import type {
TimeRangePickerColor,
TimeRangePickerSize,
TimeRangePickerVariant
} from './types';
const TIME_RANGE_PICKER_VISUAL_CONTEXT = Symbol('uix.eidos.time-range-picker.visual');
export interface TimeRangePickerVisualContext {
getSize(): TimeRangePickerSize;
getVariant(): TimeRangePickerVariant;
getColor(): TimeRangePickerColor;
/**
* Whether to render the labelled ruler scale next to each slider.
* `true` by default; pass `ruler={false}` on `<TimeRangePicker>` to hide
* the rulers entirely, or `ruler={false}` on an individual slider eidos
* wrapper to opt that one out.
*/
getRuler(): boolean;
}
export function setTimeRangePickerVisualContext(
ctx: TimeRangePickerVisualContext
): TimeRangePickerVisualContext {
setContext(TIME_RANGE_PICKER_VISUAL_CONTEXT, ctx);
return ctx;
}
export function getTimeRangePickerVisualContext(): TimeRangePickerVisualContext | undefined {
return getContext<TimeRangePickerVisualContext | undefined>(TIME_RANGE_PICKER_VISUAL_CONTEXT);
}

@ -0,0 +1,93 @@
import TimeRangePickerComponent from './time-range-picker.svelte';
import Trigger from './time-range-picker-trigger.svelte';
import Content from './time-range-picker-content.svelte';
import Clock from './time-range-picker-clock.svelte';
// PickerShell parts — shared by every composite picker.
import Footer from '../picker-shell/picker-shell-footer.svelte';
import Clear from '../picker-shell/picker-shell-clear.svelte';
import Cancel from '../picker-shell/picker-shell-cancel.svelte';
import Close from '../picker-shell/picker-shell-close.svelte';
import HourSlider from './time-range-picker-hour-slider.svelte';
import MinuteSlider from './time-range-picker-minute-slider.svelte';
import SecondSlider from './time-range-picker-second-slider.svelte';
import DayPeriodToggle from './time-range-picker-day-period-toggle.svelte';
// Field surface is re-exported direct from soma — eidos shares the
// time-range-field styling layer via [data-time-range-field] selectors.
import * as TimeRangePickerSoma from '$soma/components/time-range-picker';
import Anchor from '../popover/popover-anchor.svelte';
import Overlay from '../popover/popover-overlay.svelte';
import Arrow from '../popover/popover-arrow.svelte';
import { Portal } from '$soma/components/internal';
const Label = TimeRangePickerSoma.Label;
const Input = TimeRangePickerSoma.Input;
const Segment = TimeRangePickerSoma.Segment;
type TimeRangePickerNamespace = typeof TimeRangePickerComponent & {
Trigger: typeof Trigger;
Portal: typeof Portal;
Anchor: typeof Anchor;
Overlay: typeof Overlay;
Content: typeof Content;
Arrow: typeof Arrow;
Clock: typeof Clock;
Footer: typeof Footer;
Clear: typeof Clear;
Cancel: typeof Cancel;
Close: typeof Close;
HourSlider: typeof HourSlider;
MinuteSlider: typeof MinuteSlider;
SecondSlider: typeof SecondSlider;
DayPeriodToggle: typeof DayPeriodToggle;
Label: typeof Label;
Input: typeof Input;
Segment: typeof Segment;
};
const TimeRangePicker = TimeRangePickerComponent as TimeRangePickerNamespace;
TimeRangePicker.Trigger = Trigger;
TimeRangePicker.Portal = Portal;
TimeRangePicker.Anchor = Anchor;
TimeRangePicker.Overlay = Overlay;
TimeRangePicker.Content = Content;
TimeRangePicker.Arrow = Arrow;
TimeRangePicker.Clock = Clock;
TimeRangePicker.Footer = Footer;
TimeRangePicker.Clear = Clear;
TimeRangePicker.Cancel = Cancel;
TimeRangePicker.Close = Close;
TimeRangePicker.HourSlider = HourSlider;
TimeRangePicker.MinuteSlider = MinuteSlider;
TimeRangePicker.SecondSlider = SecondSlider;
TimeRangePicker.DayPeriodToggle = DayPeriodToggle;
TimeRangePicker.Label = Label;
TimeRangePicker.Input = Input;
TimeRangePicker.Segment = Segment;
export { TimeRangePicker };
export default TimeRangePicker;
export type {
TimeRangePickerProps,
TimeRangePickerTriggerProps as TriggerProps,
TimeRangePickerContentProps as ContentProps,
TimeRangePickerClockProps as ClockProps,
TimeRangePickerFooterProps as FooterProps,
TimeRangePickerClearProps as ClearProps,
TimeRangePickerCancelProps as CancelProps,
TimeRangePickerCloseProps as CloseProps,
TimeRangePickerLabelProps as LabelProps,
TimeRangePickerInputProps as InputProps,
TimeRangePickerSegmentProps as SegmentProps,
TimeRangePickerHiddenInputProps as HiddenInputProps,
TimeRangePickerAnchorProps as AnchorProps,
TimeRangePickerOverlayProps as OverlayProps,
TimeRangePickerArrowProps as ArrowProps,
TimeRangePickerSliderProps as SliderProps,
TimeRangePickerDayPeriodToggleProps as DayPeriodToggleProps,
InputSnippetProps,
TimeRangePickerSize,
TimeRangePickerVariant,
TimeRangePickerColor
} from './types';

@ -0,0 +1,23 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangePicker from '$soma/components/time-range-picker';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerClockProps } from './types';
let { size, variant, color, children, ...rest }: TimeRangePickerClockProps = $props();
const eidos = ActiveEidos.require();
const visual = getTimeRangePickerVisualContext();
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'ghost');
const resolvedColor = $derived(color ?? visual?.getColor() ?? 'primary');
</script>
<TimeRangePicker.Clock
{...rest}
data-size={resolvedSize}
data-variant={resolvedVariant}
data-color={resolvedColor}
>
{@render children?.()}
</TimeRangePicker.Clock>

@ -0,0 +1,71 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import PopoverContent from '../popover/popover-content.svelte';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerContentProps } from './types';
let {
size,
align = 'end',
sideOffset = 10,
collisionPadding = 12,
width,
minWidth,
maxWidth,
style,
children: bodyContent,
...rest
}: TimeRangePickerContentProps = $props();
const eidos = ActiveEidos.require();
const visual = getTimeRangePickerVisualContext();
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
function serializeLength(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? `${value}px` : undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function dimensionVar(name: string, value: string | number | undefined): string | undefined {
const serialized = serializeLength(value);
return serialized ? `${name}: ${serialized};` : undefined;
}
function resolveLength(value: typeof width, fallback: string): string | number | undefined {
const resolved = eidos.resolve(value);
if (typeof resolved === 'string' || typeof resolved === 'number') return resolved;
if (typeof value === 'string' || typeof value === 'number') return value;
return fallback;
}
const dimensionsStyle = $derived(
[
dimensionVar('--_popover-content-width-override', resolveLength(width, 'max-content')),
dimensionVar('--_popover-content-min-width-override', resolveLength(minWidth, 'max-content')),
dimensionVar(
'--_popover-content-max-width-override',
resolveLength(maxWidth, 'calc(100vw - var(--space-4))')
)
]
.filter(Boolean)
.join(' ')
);
const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle));
</script>
<PopoverContent
{...rest}
{align}
{sideOffset}
{collisionPadding}
size={resolvedSize}
style={composedStyle}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</PopoverContent>

@ -0,0 +1,10 @@
<script lang="ts">
import * as TimeRangePicker from '$soma/components/time-range-picker';
import type { TimeRangePickerDayPeriodToggleProps } from './types';
let { children, ...rest }: TimeRangePickerDayPeriodToggleProps = $props();
</script>
<TimeRangePicker.DayPeriodToggle {...rest}>
{@render children?.()}
</TimeRangePicker.DayPeriodToggle>

@ -0,0 +1,61 @@
<script lang="ts">
import * as TimeRangePicker from '$soma/components/time-range-picker';
import { TimeRangePickerProvider } from '$soma/components/time-range-picker';
import * as Slider from '$soma/components/slider';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerSliderProps } from './types';
type Props = TimeRangePickerSliderProps & {
/** Show the labelled ruler above the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
};
let { children, ruler, type, ...rest }: Props = $props();
const provider = TimeRangePickerProvider.require();
const visual = getTimeRangePickerVisualContext();
const cycle = $derived(provider.resolvedHourCycle);
// Endpoint-inclusive scale. 24h shows full available range.
const tickValues = $derived.by(() => {
if (cycle === 12) return [1, 3, 6, 9, 12];
return [0, 3, 6, 9, 12, 15, 18, 21, 23];
});
const min = $derived(provider.hourMin);
const max = $derived(provider.hourMax);
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
function percent(v: number): string {
const t = (v - min) / (max - min || 1);
return `${Math.max(0, Math.min(1, t)) * 100}%`;
}
</script>
<div
data-time-range-picker-hour-slider
data-endpoint={type}
class="time-range-picker-slider time-range-picker-hour-slider"
>
{#if showRuler}
<div class="time-range-picker-scale" aria-hidden="true">
{#each tickValues as v}
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
<span class="time-range-picker-scale-mark"></span>
<span class="time-range-picker-scale-label"
>{cycle === 12 ? v : String(v).padStart(2, '0')}</span
>
</span>
{/each}
</div>
{/if}
<TimeRangePicker.HourSlider {type} {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{/if}
</TimeRangePicker.HourSlider>
</div>

@ -0,0 +1,49 @@
<script lang="ts">
import * as TimeRangePicker from '$soma/components/time-range-picker';
import * as Slider from '$soma/components/slider';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerSliderProps } from './types';
type Props = TimeRangePickerSliderProps & {
ruler?: boolean;
};
let { children, ruler, type, ...rest }: Props = $props();
const visual = getTimeRangePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const tickValues = [0, 15, 30, 45, 59];
const min = 0;
const max = 59;
function percent(v: number): string {
const t = (v - min) / (max - min || 1);
return `${Math.max(0, Math.min(1, t)) * 100}%`;
}
</script>
<div
data-time-range-picker-minute-slider
data-endpoint={type}
class="time-range-picker-slider time-range-picker-minute-slider"
>
{#if showRuler}
<div class="time-range-picker-scale time-range-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
<span class="time-range-picker-scale-mark"></span>
<span class="time-range-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
<TimeRangePicker.MinuteSlider {type} {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{/if}
</TimeRangePicker.MinuteSlider>
</div>

@ -0,0 +1,49 @@
<script lang="ts">
import * as TimeRangePicker from '$soma/components/time-range-picker';
import * as Slider from '$soma/components/slider';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerSliderProps } from './types';
type Props = TimeRangePickerSliderProps & {
ruler?: boolean;
};
let { children, ruler, type, ...rest }: Props = $props();
const visual = getTimeRangePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const tickValues = [0, 15, 30, 45, 59];
const min = 0;
const max = 59;
function percent(v: number): string {
const t = (v - min) / (max - min || 1);
return `${Math.max(0, Math.min(1, t)) * 100}%`;
}
</script>
<div
data-time-range-picker-second-slider
data-endpoint={type}
class="time-range-picker-slider time-range-picker-second-slider"
>
{#if showRuler}
<div class="time-range-picker-scale time-range-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
<span class="time-range-picker-scale-mark"></span>
<span class="time-range-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
<TimeRangePicker.SecondSlider {type} {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{/if}
</TimeRangePicker.SecondSlider>
</div>

@ -0,0 +1,28 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangePicker from '$soma/components/time-range-picker';
import ClockIcon from '../icon/lib/clock.svelte';
import { getTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerTriggerProps } from './types';
let { size, variant, color, children, ...rest }: TimeRangePickerTriggerProps = $props();
const eidos = ActiveEidos.require();
const visual = getTimeRangePickerVisualContext();
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'surface');
const resolvedColor = $derived(color ?? visual?.getColor() ?? 'primary');
</script>
<TimeRangePicker.Trigger
{...rest}
data-size={resolvedSize}
data-variant={resolvedVariant}
data-color={resolvedColor}
>
{#if children}
{@render children()}
{:else}
<ClockIcon size="1em" decorative />
{/if}
</TimeRangePicker.Trigger>

@ -0,0 +1,320 @@
/* TimeRangePicker — visual layer.
*
* Mirrors the TimePicker recipe. Reuses time-field tokens for the field
* portion (Input + Segments) and the popover surface tokens for the clock
* body. Adds an endpoint header above each slider pair so users can tell
* which slider drives the start vs. end of the range.
*/
[data-time-range-picker] {
--_time-field-height: var(--time-field-height-md);
--_time-field-px: var(--time-field-px-md);
--_time-field-gap: var(--time-field-gap-md);
--_time-field-segment-px: var(--time-field-segment-px-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-range-picker][data-size='sm'] {
--_time-field-height: var(--time-field-height-sm);
--_time-field-px: var(--time-field-px-sm);
--_time-field-gap: var(--time-field-gap-sm);
--_time-field-segment-px: var(--time-field-segment-px-sm);
--_time-field-font-size: var(--time-field-font-size-sm);
}
[data-time-range-picker][data-size='lg'] {
--_time-field-height: var(--time-field-height-lg);
--_time-field-px: var(--time-field-px-lg);
--_time-field-gap: var(--time-field-gap-lg);
--_time-field-segment-px: var(--time-field-segment-px-lg);
--_time-field-font-size: var(--time-field-font-size-lg);
}
[data-time-range-picker][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-range-picker][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-range-picker][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);
}
[data-time-range-picker][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);
}
[data-time-range-picker][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-range-picker][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-range-picker][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);
}
/* ── Trigger ─────────────────────────────────────────────────────────────── */
[data-time-range-picker-trigger] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_time-field-height);
block-size: var(--_time-field-height);
padding: 0;
border: var(--time-field-border-width) solid var(--time-field-border);
border-radius: var(--time-field-radius);
background: var(--time-field-bg);
color: inherit;
cursor: pointer;
transition:
background var(--time-field-transition-duration) var(--time-field-transition-ease),
border-color var(--time-field-transition-duration) var(--time-field-transition-ease),
box-shadow var(--time-field-transition-duration) var(--time-field-transition-ease),
color var(--time-field-transition-duration) var(--time-field-transition-ease);
}
[data-time-range-picker-trigger]:hover {
border-color: var(--_time-field-accent-border);
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
}
[data-time-range-picker-trigger]:focus-visible {
outline: none;
border-color: var(--_time-field-accent-border);
box-shadow: var(--time-field-focus-shadow);
}
[data-time-range-picker-trigger][data-disabled],
[data-time-range-picker-trigger]:disabled {
cursor: not-allowed;
opacity: var(--time-field-disabled-opacity);
}
/* Embedded trigger inside the input — flush affordance like time-picker. */
[data-time-range-field-input] [data-time-range-picker-trigger] {
flex: 0 0 auto;
inline-size: var(--time-field-segment-height);
block-size: var(--time-field-segment-height);
margin-inline-start: auto;
padding: 0;
border: none;
border-radius: var(--time-field-segment-radius);
background: transparent;
color: var(--time-field-segment-color);
box-shadow: none;
}
[data-time-range-field-input] [data-time-range-picker-trigger]:hover {
border: none;
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
}
[data-time-range-field-input] [data-time-range-picker-trigger]:focus-visible {
outline: none;
border: none;
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
box-shadow: var(--time-field-segment-focus-shadow);
}
/* ── Popover content / Clock surface ─────────────────────────────────────── */
[data-popover-content]:has([data-time-range-picker-clock]) {
--_popover-content-width-override: max-content;
--_popover-content-min-width-override: max-content;
--_popover-content-max-width-override: calc(100vw - var(--space-4));
}
[data-time-range-picker-clock] {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-3);
min-inline-size: 32rem;
color: var(--color-content-primary);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
line-height: var(--leading-ui);
}
@media (max-width: 40rem) {
[data-time-range-picker-clock] {
grid-template-columns: minmax(0, 1fr);
min-inline-size: 18rem;
}
}
[data-time-range-picker-clock] [data-slider] {
inline-size: 100%;
}
/* Footer spans both endpoint columns. */
[data-time-range-picker-clock] [data-picker-footer] {
grid-column: 1 / -1;
}
/* Endpoint column — wraps a stack of sliders + label. The eidos demo
provides the column structure; the recipe only styles the column. */
.time-range-picker-endpoint {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-inline-size: 0;
}
.time-range-picker-endpoint-label {
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-content-muted);
}
/* ── Labelled scale (ruler) ─────────────────────────────────────────────── */
.time-range-picker-slider {
display: flex;
flex-direction: column;
gap: var(--space-1);
inline-size: 100%;
}
.time-range-picker-scale {
position: relative;
block-size: 1.25rem;
inline-size: 100%;
padding-inline: calc(var(--_slider-thumb-size, 1rem) / 2);
box-sizing: border-box;
}
.time-range-picker-scale-tick {
position: absolute;
inset-block: 0;
transform: translateX(-50%);
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1px;
color: var(--color-content-muted);
font-family: var(--font-mono, var(--font-ui));
font-size: var(--font-size-xs);
line-height: 1;
pointer-events: none;
}
.time-range-picker-scale-mark {
display: block;
inline-size: 1px;
block-size: 6px;
background: var(--color-border-default);
}
.time-range-picker-scale-label {
display: block;
}
.time-range-picker-scale-below .time-range-picker-scale-tick {
flex-direction: column-reverse;
}
/* ── Slider thumb / range emphasis inside the clock ──────────────────────── */
[data-time-range-picker-clock] [data-slider-thumb] {
border-color: var(--_time-field-accent-border);
box-shadow:
var(--slider-thumb-shadow),
0 0 0 2px var(--color-surface-default);
}
[data-time-range-picker-clock] [data-slider-thumb]:focus-visible {
box-shadow:
var(--slider-thumb-focus-shadow),
0 0 0 2px var(--color-surface-default);
}
[data-time-range-picker-clock] [data-slider-range] {
background: var(--_time-field-accent-border);
}
/* Visually distinguish end-endpoint sliders with a slightly different track tint. */
[data-time-range-picker-clock] [data-endpoint='end'] [data-slider-range] {
background: color-mix(in oklch, var(--_time-field-accent-border) 80%, var(--color-content-muted));
}
/* ── Day period toggle ───────────────────────────────────────────────────── */
[data-time-range-picker-day-period-toggle] {
display: inline-flex;
gap: var(--space-1);
padding: 2px;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-default);
}
[data-time-range-picker-day-period-item] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: 2.25rem;
padding-inline: var(--space-2);
padding-block: var(--space-1);
border-radius: calc(var(--radius-md) - 2px);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-content-muted);
cursor: pointer;
}
[data-time-range-picker-day-period-item][data-checked] {
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
}
[data-time-range-picker-day-period-item] input[type='radio'] {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
border: 0;
clip: rect(0 0 0 0);
overflow: hidden;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
[data-time-range-picker-trigger] {
transition: none;
}
}

@ -0,0 +1,40 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangePicker from '$soma/components/time-range-picker';
import { setTimeRangePickerVisualContext } from './context.svelte';
import type { TimeRangePickerProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
ruler = true,
value = $bindable(),
placeholder = $bindable(),
open = $bindable(false),
children,
...rest
}: TimeRangePickerProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
setTimeRangePickerVisualContext({
getSize: () => resolvedSize,
getVariant: () => variant,
getColor: () => color,
getRuler: () => ruler
});
</script>
<TimeRangePicker.Provider
{...rest}
bind:value
bind:placeholder
bind:open
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{@render children?.()}
</TimeRangePicker.Provider>

@ -0,0 +1,90 @@
import type {
ContentProps as SomaTimeRangePickerContentProps,
InputProps as SomaTimeRangePickerInputProps,
InputSnippetProps as TimeRangePickerInputSnippetProps,
LabelProps as SomaTimeRangePickerLabelProps,
ProviderProps,
SegmentProps as SomaTimeRangePickerSegmentProps,
SliderProps as SomaTimeRangePickerSliderProps,
SliderThumbProps as SomaTimeRangePickerSliderThumbProps,
SliderRangeProps as SomaTimeRangePickerSliderRangeProps,
SliderTickProps as SomaTimeRangePickerSliderTickProps,
TriggerProps as SomaTimeRangePickerTriggerProps,
DayPeriodToggleProps as SomaTimeRangePickerDayPeriodToggleProps
} from '$soma/components/time-range-picker';
import type {
PopoverAnchorProps,
PopoverContentProps,
PopoverOverlayProps,
PopoverArrowProps,
PopoverCloseProps
} from '../popover/types';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { Snippet } from 'svelte';
export type TimeRangePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type TimeRangePickerVariant = ControlVariant;
export type TimeRangePickerColor = ColorRole;
export type TimeRangePickerProps = ProviderProps & {
/** Visual size for input segments, trigger and clock. @default 'md' */
size?: ResponsiveProp<TimeRangePickerSize>;
/** Visual treatment shared by the field and clock. @default 'surface' */
variant?: TimeRangePickerVariant;
/** Intent palette used for focus and selected affordances. @default 'primary' */
color?: TimeRangePickerColor;
/**
* Show the labelled ruler scale next to each slider in the clock popover.
* @default true
*/
ruler?: boolean;
};
export type TimeRangePickerTriggerProps = SomaTimeRangePickerTriggerProps & {
size?: ResponsiveProp<TimeRangePickerSize>;
variant?: TimeRangePickerVariant;
color?: TimeRangePickerColor;
};
export type TimeRangePickerContentProps = Omit<PopoverContentProps, 'size'> & {
size?: ResponsiveProp<TimeRangePickerSize>;
};
export type TimeRangePickerClockProps = {
id?: string;
size?: ResponsiveProp<TimeRangePickerSize>;
variant?: TimeRangePickerVariant;
color?: TimeRangePickerColor;
class?: string;
style?: string;
children?: Snippet;
};
// PickerShell action-row props — Footer/Clear/Cancel/Close are shared.
export type {
FooterProps as TimeRangePickerFooterProps,
ClearProps as TimeRangePickerClearProps,
CancelProps as TimeRangePickerCancelProps,
CloseProps as TimeRangePickerCloseProps
} from '../picker-shell';
export type TimeRangePickerLabelProps = SomaTimeRangePickerLabelProps;
export type TimeRangePickerInputProps = SomaTimeRangePickerInputProps;
export type TimeRangePickerSegmentProps = SomaTimeRangePickerSegmentProps;
export type TimeRangePickerHiddenInputProps = Record<string, never>;
export type TimeRangePickerAnchorProps = PopoverAnchorProps;
export type TimeRangePickerOverlayProps = PopoverOverlayProps;
export type TimeRangePickerArrowProps = PopoverArrowProps;
export type TimeRangePickerPopoverCloseProps = PopoverCloseProps;
export type TimeRangePickerSliderProps = SomaTimeRangePickerSliderProps;
export type TimeRangePickerSliderThumbProps = SomaTimeRangePickerSliderThumbProps;
export type TimeRangePickerSliderRangeProps = SomaTimeRangePickerSliderRangeProps;
export type TimeRangePickerSliderTickProps = SomaTimeRangePickerSliderTickProps;
export type TimeRangePickerDayPeriodToggleProps = SomaTimeRangePickerDayPeriodToggleProps;
export type {
TimeRangePickerInputSnippetProps,
TimeRangePickerInputSnippetProps as InputSnippetProps
};

@ -93,6 +93,7 @@
@import './components/color-field/color-field.css';
@import './components/picker-shell/picker-shell.css';
@import './components/time-picker/time-picker.css';
@import './components/time-range-picker/time-range-picker.css';
@import './components/color-picker/color-picker.css';
@import './components/slider/slider.css';
@import './components/toolbar/toolbar.css';

@ -7,9 +7,107 @@ export const colorPickerMorfo = {
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
events: [
// Area drag — same shape as the slider's `handle-*` events so sema
// consumers can hook the family/verb uniformly (sounds, haptics,
// transient styling) regardless of which dragger emitted.
// ── Canonical picker-shell events (parity with date-picker /
// time-picker / time-range-picker). They drive Footer Save / Cancel /
// Close and outside-click dismissal. The popover surface is the
// `content` part — same shape as the other pickers.
{
name: 'open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('content'),
sequence: 'pre',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'risk']
}
},
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-commit',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('content'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'committed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'commit-clear',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('provider'),
sequence: 'post',
intent: 'neutral'
}
},
// ── Area-drag events (sama family as the slider's `handle-*`) ───
{
name: 'handle-pick',
semantic: {
@ -30,7 +128,7 @@ export const colorPickerMorfo = {
},
// Value commit — fires on pointer release (area thumb / channel
// slider / swatch click / eyedropper pick). `commit-set` is the
// canonical "save" signal.
// canonical "save" signal for non-shell commits.
{
name: 'commit-set',
semantic: {
@ -76,7 +174,9 @@ export const colorPickerMorfo = {
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-invalid', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },

@ -1,21 +1,136 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* TimeRangePicker — composite popover containing a TimeRangeField (segmented
* start + end inputs) and a Clock with per-endpoint sliders + AM/PM toggles.
* Same contract as TimePicker (mode + commit/cancel/clear via PickerShell)
* but with a range state machine (empty → pending → complete).
*/
export const timeRangePickerMorfo = {
name: 'TimeRangePicker',
kebab: 'time-range-picker',
scope: ['soma'],
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/',
texts: {
label: '#?components.time-range-picker.label|Time Range Picker',
trigger: '#?components.time-range-picker.trigger|Open time range picker',
clock: '#?components.time-range-picker.clock|Time range clock',
'start-hour-slider': '#?components.time-range-picker.start-hour-slider|Start hour',
'end-hour-slider': '#?components.time-range-picker.end-hour-slider|End hour',
'start-minute-slider': '#?components.time-range-picker.start-minute-slider|Start minute',
'end-minute-slider': '#?components.time-range-picker.end-minute-slider|End minute',
'start-second-slider': '#?components.time-range-picker.start-second-slider|Start second',
'end-second-slider': '#?components.time-range-picker.end-second-slider|End second'
'end-second-slider': '#?components.time-range-picker.end-second-slider|End second',
'day-period': '#?components.time-range-picker.day-period|AM / PM',
clear: '#?components.time-range-picker.clear|Clear time range',
cancel: '#?components.time-range-picker.cancel|Cancel',
close: '#?components.time-range-picker.close|Close'
},
events: [
{
name: 'open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('clock'),
sequence: 'pre',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'risk']
}
},
commits: {
part: v.partRef('clock'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-commit',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('clock'),
sequence: 'post',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('clock'), attr: 'data-last-action', value: 'committed' }],
commits: {
part: v.partRef('clock'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('clock'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('clock'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('clock'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('clock'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [{ part: v.partRef('clock'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('clock'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
semantic: {
family: 'emerge',
verb: 'dismiss',
target: v.partRef('clock'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{
part: v.partRef('clock'),
attr: 'data-last-action',
value: 'dismissed-outside'
}
],
commits: {
part: v.partRef('clock'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'commit-clear',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('clock'),
sequence: 'post',
intent: 'neutral'
}
}
],
focus: {
initial: 'first-focusable',
trap: true,
@ -28,11 +143,22 @@ export const timeRangePickerMorfo = {
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: [
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'aria-required', value: v.propRef('required'), severity: 'optional' }
]
},
{
name: 'Trigger',
@ -41,48 +167,111 @@ export const timeRangePickerMorfo = {
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
optional: true,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-haspopup', value: v.literal('dialog') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-controls',
value: v.partRef('clock'),
condition: { when: 'part-present', part: 'clock' },
severity: 'recommended'
},
{
attr: 'aria-label',
value: v.translationRef('#?components.time-range-picker.trigger|Open time range picker'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Enter', action: 'open' },
{ key: ' ', action: 'open' }
]
},
{
name: 'Clock',
kebab: 'clock',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'dialog',
optional: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-last-action',
values: ['committed', 'cancelled', 'dismissed', 'dismissed-outside'],
severity: 'optional'
},
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
},
{
attr: 'data-ending-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
}
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.time-range-picker.clock|Time range clock'),
severity: 'recommended'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
},
{
// Hour / Minute / Second sliders — composite parts. The outer
// wrapper carries `data-time-range-picker-{X}-slider` plus
// `data-endpoint`. The inner slider DOM is declared by the
// slider morfo (data-slider, data-slider-thumb, data-slider-range).
name: 'HourSlider',
kebab: 'hour-slider',
archetype: 'input',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-endpoint', values: ['start', 'end'] }],
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-endpoint', values: ['start', 'end'] }
],
aria: []
},
{
name: 'MinuteSlider',
kebab: 'minute-slider',
archetype: 'input',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-endpoint', values: ['start', 'end'] }],
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-endpoint', values: ['start', 'end'] }
],
aria: []
},
{
name: 'SecondSlider',
kebab: 'second-slider',
archetype: 'input',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-endpoint', values: ['start', 'end'] }],
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-endpoint', values: ['start', 'end'] }
],
aria: []
},
{
@ -93,8 +282,17 @@ export const timeRangePickerMorfo = {
defaultElement: 'div',
role: 'radiogroup',
optional: true,
data: [{ attr: 'data-endpoint', values: ['start', 'end'] }],
aria: []
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-endpoint', values: ['start', 'end'] }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.time-range-picker.day-period|AM / PM'),
severity: 'recommended'
}
]
},
{
name: 'DayPeriodItem',
@ -106,5 +304,8 @@ export const timeRangePickerMorfo = {
data: [{ attr: 'data-checked', severity: 'optional' }],
aria: []
}
// Footer / Clear / Cancel / Close — chrome shared via picker-shell;
// not declared here. Eidos `<TimeRangePicker.Footer/Clear/Cancel/Close>`
// re-exports the `<PickerShell.…>` parts.
]
} as const satisfies Morfo;

@ -179,7 +179,15 @@ export class ColorPickerProvider {
private constructor(opts: ColorPickerOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(colorPickerMorfo, {});
this.runtime = this.soma.runtime(colorPickerMorfo, {
states: { open: () => this.opts.open.current },
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
required: () => this.opts.required.current,
invalid: () => this.isInvalid
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,

@ -0,0 +1,46 @@
<script lang="ts">
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import {
TimeRangePickerProvider,
timeRangePickerAttrs
} from '../time-range-picker-provider.svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface ClockProps extends HTMLAttributes<HTMLDivElement> {
id?: string;
ref?: HTMLElement | null;
children?: Snippet;
child?: Snippet<[{ props: Record<string, unknown> }]>;
}
const uid = $props.id();
let {
id = createId(uid, 'time-range-picker-clock'),
ref = $bindable(null),
children,
child,
...restProps
}: ClockProps = $props();
const provider = TimeRangePickerProvider.require();
const mergedProps = $derived(
mergeProps(restProps, {
id,
role: 'dialog' as const,
[timeRangePickerAttrs.clock]: '',
'data-state': provider.opts.open.current ? 'open' : 'closed'
} as Record<string, unknown>)
);
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps} bind:this={ref}>
{@render children?.()}
</div>
{/if}

@ -1,10 +1,11 @@
<script lang="ts">
import { watch } from 'runed';
import {
readableActive,
writableActive,
state
} from '$libs/reactive';
readableActive,
writableActive,
state
} from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { Soma } from '../../../core/soma.svelte';
import { Time, type TimeValue, type TimeRange } from '$libs/days';
@ -18,6 +19,7 @@
let {
id = createId(uid, 'time-range-picker'),
ref = $bindable(null),
value = $bindable<TimeRange | undefined>(),
onValueChange = () => {},
onStartValueChange = () => {},
@ -28,6 +30,7 @@
onOpenChange = () => {},
onOpenChangeComplete = () => {},
closeOnRangeComplete = false,
mode = 'inline',
validate,
onInvalid,
minValue,
@ -46,7 +49,9 @@
minuteStep = 1,
secondStep = 1,
errorMessageId,
children
children,
child,
...restProps
}: TimeRangePickerProviderProps = $props();
/** Default placeholder = current wall-clock time (minute precision). */
@ -126,13 +131,18 @@
);
// ── Shared state via picker context ──────────────────────────────────────
TimeRangePickerProvider.create({
const state_ = TimeRangePickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: sharedValue,
placeholder: sharedPlaceholder,
startValue,
endValue,
open: sharedOpen,
mode: readableActive(() => mode),
validate: readableActive(() => validate),
onInvalid: readableActive(() => onInvalid),
minValue: readableActive(() => minValue),
@ -160,7 +170,7 @@
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => false)
modal: readableActive(() => mode === 'modal')
});
// ── TimeRangeField wired to shared value / config ────────────────────────
@ -190,6 +200,14 @@
dir: resolvedDir,
errorMessageId: readableActive(() => errorMessageId)
});
const mergedProps = $derived(mergeProps(restProps, state_.props));
</script>
{@render children?.()}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -1,6 +1,7 @@
// Root + unique wrappers
export { default as Provider } from './components/time-range-picker.svelte';
export { default as Trigger } from './components/time-range-picker-trigger.svelte';
export { default as Clock } from './components/time-range-picker-clock.svelte';
export { default as HourSlider } from './components/time-range-picker-hour-slider.svelte';
export { default as MinuteSlider } from './components/time-range-picker-minute-slider.svelte';
export { default as SecondSlider } from './components/time-range-picker-second-slider.svelte';
@ -15,6 +16,12 @@ export { Label, Input, Segment } from '../time-range-field/exports';
// Slider sub-parts (thumb / range / tick)
export { Thumb, Range, Tick } from '../slider/exports';
// Provider class + attrs map (consumed by per-endpoint slider wrappers).
export {
TimeRangePickerProvider,
timeRangePickerAttrs
} from './time-range-picker-provider.svelte';
export type {
TimeRangePickerProviderProps as ProviderProps,
TimeRangePickerTriggerProps as TriggerProps,

@ -73,6 +73,7 @@ function createTimeRangePickerOpts() {
endReadonlySegments: state<EditableTimeSegmentPart[]>(['dayPeriod']),
required: state(false),
closeOnRangeComplete: state(true),
mode: state<'inline' | 'modal'>('inline'),
granularity: state<TimeGranularity | undefined>(undefined),
hideTimeZone: state(false),
hourCycle: state<HourCycle | undefined>(undefined),

@ -1,5 +1,8 @@
import { untrack } from 'svelte';
import { watch } from 'runed';
import { context, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, boolToStr, getDataOpenClosed } from '$adom';
import { type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn, Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
@ -13,6 +16,11 @@ import type {
HourCycle
} from '$libs/days';
import type { TimeRangeEndpoint } from './types';
import {
pickerShellContext,
type PickerShellHandle,
type PickerShellMode
} from '../picker-shell';
import { timeRangePickerMorfo } from '../../../morfo/components/time-range-picker';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
@ -41,6 +49,7 @@ export interface TimeRangePickerOpts
endReadonlySegments: EditableTimeSegmentPart[];
required: boolean;
closeOnRangeComplete: boolean;
mode: PickerShellMode;
granularity: TimeGranularity | undefined;
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
@ -58,6 +67,10 @@ export interface TimeRangePickerOpts
* a `PopoverProvider` and a `TimeRangeFieldProvider` tied to the same state;
* per-endpoint slider wrappers read from this provider to drive start/end
* independently.
*
* Picker-shell contract (mode + commit/cancel/clear + snapshot) follows the
* same shape as date-picker / time-picker / color-picker so the shared
* `<PickerShell.Footer/Clear/Cancel/Close>` parts work out of the box.
*/
export class TimeRangePickerProvider {
readonly opts: TimeRangePickerOpts;
@ -75,18 +88,53 @@ export class TimeRangePickerProvider {
return new TimeRangePickerProvider(opts);
}
/** Snapshot of both endpoints captured on the OPEN edge for `cancel()`. */
private valueOnOpen: { start: TimeValue | undefined; end: TimeValue | undefined } | undefined =
undefined;
private constructor(opts: TimeRangePickerOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(timeRangePickerMorfo, {});
this.runtime = this.soma.runtime(timeRangePickerMorfo, {
states: { open: () => this.opts.open.current },
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
required: () => this.opts.required.current
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: TimeRangePickerProvider.ctx
});
// Capture both endpoints on the opening edge for `cancel()` revert.
watch(
() => this.opts.open.current,
(now, prev) => {
if (now && !prev) {
this.valueOnOpen = untrack(() => ({
start: this.opts.startValue.current,
end: this.opts.endValue.current
}));
}
}
);
// Expose our commit/cancel/clear/mode handle to descendant Eidos
// PickerShell parts.
pickerShellContext.set(this.pickerShellHandle);
}
readonly pickerShellHandle: PickerShellHandle = {
getMode: () => this.opts.mode.current,
commit: () => this.commit(),
cancel: () => this.cancel(),
clear: () => this.clear()
};
/** Resolved hour cycle — same rule as TimeField/TimePicker. */
readonly resolvedHourCycle: HourCycle = $derived.by(() => {
const explicit = this.opts.hourCycle.current;
@ -188,6 +236,44 @@ export class TimeRangePickerProvider {
this.opts.open.current = false;
}
}
// ── PickerShell handle (mode + commit/cancel/clear) ────────────────────
/** Save current range and close the popover. */
commit(): void {
this.opts.open.current = false;
}
/** Discard mid-session edits (revert to OPEN-edge snapshot) and close. */
cancel(): void {
const snapshot = this.valueOnOpen;
if (snapshot) {
this.opts.startValue.current = snapshot.start;
this.opts.endValue.current = snapshot.end;
}
this.opts.open.current = false;
}
/** Reset both endpoints to undefined without closing. */
clear(): void {
this.opts.startValue.current = undefined;
this.opts.endValue.current = undefined;
}
// ── Root props (emitted onto `[data-time-range-picker]`) ───────────────
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-state': getDataOpenClosed(this.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-required': boolToEmptyStrOrUndef(this.opts.required.current),
'aria-disabled': boolToStr(this.opts.disabled.current),
'aria-readonly': boolToStr(this.opts.readonly.current),
'aria-required': boolToStr(this.opts.required.current)
} as const)
);
}
export { attrs as timeRangePickerAttrs };

@ -1,4 +1,3 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
import type {
@ -10,12 +9,12 @@ import type {
TimeGranularity,
HourCycle
} from '$libs/days';
import type { PickerShellMode } from '../picker-shell';
/** Which endpoint a per-endpoint sub-part drives. */
export type TimeRangeEndpoint = 'start' | 'end';
/** Props for the root `TimeRangePicker.Provider`. */
export type TimeRangePickerProviderProps = {
type TimeRangePickerBaseProps = {
id?: string;
// ── Value ──
@ -45,6 +44,13 @@ export type TimeRangePickerProviderProps = {
* @default false
*/
closeOnRangeComplete?: boolean;
/**
* Picker mode. `'inline'` closes on outside-click; `'modal'` blocks
* outside-click and requires a footer action (`Close` / `Cancel`) to
* dismiss. Same contract as date-picker / time-picker / color-picker.
* @default 'inline'
*/
mode?: PickerShellMode;
// ── Validation ──
/** Custom range validator. */
@ -82,9 +88,12 @@ export type TimeRangePickerProviderProps = {
// ── ARIA ──
errorMessageId?: string;
children?: Snippet;
};
/** Props for the root `TimeRangePicker.Provider`. */
export type TimeRangePickerProviderProps = WithChild<TimeRangePickerBaseProps> &
Without<PrimitiveDivAttributes, TimeRangePickerBaseProps>;
/** Props for `TimeRangePicker.Trigger`. */
export type TimeRangePickerTriggerProps = WithChild<{
id?: string;

@ -151,6 +151,7 @@
{ slug: '/uix/components/date-range-picker', label: 'Date range picker' },
{ slug: '/uix/components/time-field', label: 'Time 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' },
{ slug: '/uix/components/color-picker', label: 'Color picker' },
{ slug: '/uix/components/stepper', label: 'Stepper' },

@ -0,0 +1,743 @@
<script lang="ts">
import {
TimeRangePicker,
type TimeRangePickerColor,
type TimeRangePickerSize,
type TimeRangePickerVariant,
type InputSnippetProps
} from '$uix/eidos/components/time-range-picker';
import { compileMorfo } from '$uix/morfo';
import { timeRangePickerMorfo } from '@/uix/morfo/components/time-range-picker';
import { timeRangeFieldMorfo } from '@/uix/morfo/components/time-range-field';
import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix';
import {
Time,
type TimeValue,
type TimeRange,
type TimeGranularity,
type HourCycle,
type EditableTimeSegmentPart
} from '$libs/days';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const businessRange: TimeRange = {
start: new Time(9, 0, 0),
end: new Time(17, 0, 0)
};
const lunchRange: TimeRange = {
start: new Time(13, 0, 0),
end: new Time(14, 30, 0)
};
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<TimeRange | undefined>(businessRange);
let placeholder = $state<TimeValue>(new Time(9, 0, 0));
let open = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let required = $state(false);
let disabled = $state(false);
let readonly = $state(false);
let lockSeconds = $state(false);
let ruler = $state(true);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let bounds = $state(false);
let closeOnRangeComplete = $state(false);
let locale = $state('en-US');
let size = $state<TimeRangePickerSize>('md');
let variant = $state<TimeRangePickerVariant>('surface');
let color = $state<TimeRangePickerColor>('primary');
// Footer composition toggles
let showFooter = $state(true);
let showClear = $state(true);
let showCancel = $state(true);
let showClose = $state(true);
const compiled = compileMorfo(timeRangePickerMorfo);
const fieldCompiled = compileMorfo(timeRangeFieldMorfo);
const popoverCompiled = compileMorfo(popoverMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const fieldEvents = $derived([...fieldCompiled.actions.byName.values()]);
const popoverEvents = $derived([...popoverCompiled.actions.byName.values()]);
const eventRows = $derived([
...events.map((action) => ({ owner: 'TimeRangePicker', action })),
...fieldEvents.map((action) => ({ owner: 'TimeRangeField', action })),
...popoverEvents.map((action) => ({ owner: 'Popover', action }))
]);
const readonlySegments = $derived<EditableTimeSegmentPart[]>(lockSeconds ? ['second'] : []);
const valueLabel = $derived(
value?.start && value?.end
? `${value.start.toString()} → ${value.end.toString()}`
: 'empty'
);
const minValue: TimeValue = new Time(8, 0, 0);
const maxValue: TimeValue = new Time(20, 0, 0);
function sameRange(a: TimeRange | undefined, b: TimeRange): boolean {
return (
a?.start?.toString() === b.start?.toString() && a?.end?.toString() === b.end?.toString()
);
}
function setDemoValue(kind: 'business' | 'lunch' | 'empty'): void {
if (kind === 'empty') {
value = undefined;
return;
}
value = kind === 'business' ? businessRange : lunchRange;
}
function fmtTime(at: number): string {
return new Intl.DateTimeFormat(undefined, {
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 2
}).format(at);
}
$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();
});
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as TimeRangePicker from '$soma/components/time-range-picker';",
" import { Time, type TimeRange } from '$libs/days';",
'',
' let value = $state<TimeRange | undefined>({',
' start: new Time(9, 0, 0),',
' end: new Time(17, 0, 0)',
' });',
' let open = $state(false);',
'</' + 'script>',
'',
'<TimeRangePicker.Provider',
' bind:value bind:open',
mode !== 'inline' && ` mode="${mode}"`,
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
closeOnRangeComplete && ' closeOnRangeComplete',
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
'>',
' <TimeRangePicker.Input type="start">…</TimeRangePicker.Input>',
' <TimeRangePicker.Input type="end">…<TimeRangePicker.Trigger /></TimeRangePicker.Input>',
' <TimeRangePicker.Clock>',
' <TimeRangePicker.HourSlider type="start" />',
' <TimeRangePicker.MinuteSlider type="start" />',
' <TimeRangePicker.HourSlider type="end" />',
' <TimeRangePicker.MinuteSlider type="end" />',
' </TimeRangePicker.Clock>',
'</TimeRangePicker.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { TimeRangePicker } from '$uix/eidos/components/time-range-picker';",
" import { Time, type TimeRange } from '$libs/days';",
'',
' let value = $state<TimeRange | undefined>({',
' start: new Time(9, 0, 0),',
' end: new Time(17, 0, 0)',
' });',
' let open = $state(false);',
'</' + 'script>',
'',
'<TimeRangePicker',
' bind:value bind:open',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
mode !== 'inline' && ` mode="${mode}"`,
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
closeOnRangeComplete && ' closeOnRangeComplete',
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
lockSeconds && " startReadonlySegments={['second']} endReadonlySegments={['second']}",
bounds && ' minValue={new Time(8, 0, 0)} maxValue={new Time(20, 0, 0)}',
'>',
' <TimeRangePicker.Input type="start">',
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>',
' {/each}',
' {/snippet}',
' </TimeRangePicker.Input>',
' <TimeRangePicker.Input type="end">',
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>',
' {/each}',
' <TimeRangePicker.Trigger />',
' {/snippet}',
' </TimeRangePicker.Input>',
' <TimeRangePicker.Portal>',
' <TimeRangePicker.Content>',
' <TimeRangePicker.Clock>',
' <div class="time-range-picker-endpoint">',
' <span class="time-range-picker-endpoint-label">Start</span>',
' <TimeRangePicker.HourSlider type="start" />',
' <TimeRangePicker.MinuteSlider type="start" />',
granularity === 'second' && ' <TimeRangePicker.SecondSlider type="start" />',
hourCycle === 12 && ' <TimeRangePicker.DayPeriodToggle type="start" />',
' </div>',
' <div class="time-range-picker-endpoint">',
' <span class="time-range-picker-endpoint-label">End</span>',
' <TimeRangePicker.HourSlider type="end" />',
' <TimeRangePicker.MinuteSlider type="end" />',
granularity === 'second' && ' <TimeRangePicker.SecondSlider type="end" />',
hourCycle === 12 && ' <TimeRangePicker.DayPeriodToggle type="end" />',
' </div>',
showFooter &&
(showClear || showCancel || showClose) &&
' <TimeRangePicker.Footer>',
showFooter && showClear && ' <TimeRangePicker.Clear />',
showFooter && showCancel && ' <TimeRangePicker.Cancel />',
showFooter && showClose && ' <TimeRangePicker.Close />',
showFooter &&
(showClear || showCancel || showClose) &&
' </TimeRangePicker.Footer>',
' </TimeRangePicker.Clock>',
' </TimeRangePicker.Content>',
' </TimeRangePicker.Portal>',
'</TimeRangePicker>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · TimeRangePicker</div>
<h1 data-uix-page-title>TimeRangePicker</h1>
<p data-uix-page-lede>
A pair of segmented time inputs (start + end) plus a popover Clock with per-endpoint
sliders + AM/PM toggles. Soma owns the range state machine (empty → pending → complete)
and the open state; Eidos adds size, variant, intent color, the popover shell and the
canonical Footer parts.
</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>{eventRows.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>3</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: min(32rem, 100%);">
<TimeRangePicker
bind:value
bind:placeholder
bind:open
{mode}
{required}
{disabled}
{readonly}
startReadonlySegments={readonlySegments}
endReadonlySegments={readonlySegments}
{granularity}
{hourCycle}
{locale}
{ruler}
{closeOnRangeComplete}
{size}
{variant}
{color}
minValue={bounds ? minValue : undefined}
maxValue={bounds ? maxValue : undefined}
>
<TimeRangePicker.Label>Working hours</TimeRangePicker.Label>
<TimeRangePicker.Input type="start" aria-label="Start time">
{#snippet children({ segments }: InputSnippetProps)}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Input type="end" aria-label="End time">
{#snippet children({ segments }: InputSnippetProps)}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
<TimeRangePicker.Trigger />
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Portal>
<TimeRangePicker.Content>
<TimeRangePicker.Clock>
<div class="time-range-picker-endpoint">
<span class="time-range-picker-endpoint-label">Start</span>
<TimeRangePicker.HourSlider type="start" />
<TimeRangePicker.MinuteSlider type="start" />
{#if granularity === 'second'}
<TimeRangePicker.SecondSlider type="start" />
{/if}
{#if hourCycle === 12}
<TimeRangePicker.DayPeriodToggle type="start" />
{/if}
</div>
<div class="time-range-picker-endpoint">
<span class="time-range-picker-endpoint-label">End</span>
<TimeRangePicker.HourSlider type="end" />
<TimeRangePicker.MinuteSlider type="end" />
{#if granularity === 'second'}
<TimeRangePicker.SecondSlider type="end" />
{/if}
{#if hourCycle === 12}
<TimeRangePicker.DayPeriodToggle type="end" />
{/if}
</div>
{#if showFooter && (showClear || showCancel || showClose)}
<TimeRangePicker.Footer>
{#if showClear}<TimeRangePicker.Clear />{/if}
{#if showCancel}<TimeRangePicker.Cancel />{/if}
{#if showClose}<TimeRangePicker.Close />{/if}
</TimeRangePicker.Footer>
{/if}
</TimeRangePicker.Clock>
</TimeRangePicker.Content>
</TimeRangePicker.Portal>
</TimeRangePicker>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>edit, open the clock or play an event to see sema stamps</span>
{:else}
{#each trace.slice(0, 3) as entry}
<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</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>{eventRows.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>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns the range state machine, segment
editing, validation and the open state.
<span data-uix-layer-badge="eidos">eidos</span> owns visual treatment.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value + behaviour
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value</span>
<span data-uix-chips role="group">
<button
data-uix-chip
data-active={value && sameRange(value, businessRange)}
onclick={() => setDemoValue('business')}>business</button
>
<button
data-uix-chip
data-active={value && sameRange(value, lunchRange)}
onclick={() => setDemoValue('lunch')}>lunch</button
>
<button
data-uix-chip
data-active={value === undefined}
onclick={() => setDemoValue('empty')}>empty</button
>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
{#each ['inline', 'modal'] as v}
<button
data-uix-chip
data-active={mode === v}
onclick={() => (mode = v as 'inline' | 'modal')}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>granularity</span>
<span data-uix-chips role="radiogroup">
{#each ['hour', 'minute', 'second'] as v}
<button
data-uix-chip
data-active={granularity === v}
onclick={() => (granularity = v as TimeGranularity)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hour cycle</span>
<span data-uix-chips role="radiogroup">
{#each [12, 24] as v}
<button
data-uix-chip
data-active={hourCycle === v}
onclick={() => (hourCycle = v as HourCycle)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="fr-FR">fr-FR</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>states</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>required</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>disabled</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>readonly</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>constraints</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={bounds} />
<span data-uix-switch-label>min 08:00 · max 20:00</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lockSeconds} />
<span data-uix-switch-label>seconds readonly</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={closeOnRangeComplete} />
<span data-uix-switch-label>close on range complete</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>clock</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={ruler} />
<span data-uix-switch-label>show ruler scale</span>
</span>
</label>
</div>
<div data-uix-subsection-head>Footer composition</div>
<p data-uix-section-desc>
Each switch decides whether the part is <em>included</em> in the markup. The parts
never read these flags — omitting the part hides it, including it always renders.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>visibility</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showFooter} />
<span data-uix-switch-label>render footer</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClear} disabled={!showFooter} />
<span data-uix-switch-label>clear</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showCancel} disabled={!showFooter} />
<span data-uix-switch-label>cancel</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClose} disabled={!showFooter} />
<span data-uix-switch-label>close</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as v}
<button
data-uix-chip
data-active={size === v}
onclick={() => (size = v as TimeRangePickerSize)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v}
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v as TimeRangePickerVariant)}>{v}</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 v}
<button
data-uix-chip
data-active={color === v}
onclick={() => (color = v as TimeRangePickerColor)}>{v}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · provider + sliders</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root + attached parts</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 comparison</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Feature</th><th>React Aria</th><th>Ark</th><th>Chakra</th><th>UIX</th><th>Decision</th></tr>
</thead>
<tbody>
<tr><td class="name">Paired segmented inputs</td><td>yes (Date)</td><td>no</td><td>no</td><td>yes</td><td>Soma owns start/end editors.</td></tr>
<tr><td class="name">Per-endpoint sliders</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Two slider columns; share generic Slider.</td></tr>
<tr><td class="name">Range state machine</td><td>yes (Date)</td><td>n/a</td><td>n/a</td><td>yes</td><td>Empty → pending → complete coordinated in soma.</td></tr>
<tr><td class="name">mode='inline' | 'modal'</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Same contract as date / time / color pickers.</td></tr>
<tr><td class="name">Footer = composition</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>No *Button root props — pure composition.</td></tr>
<tr><td class="name">commit/cancel/clear helpers</td><td>manual</td><td>manual</td><td>manual</td><td>yes</td><td>Wired by Footer parts via picker-shell context.</td></tr>
<tr><td class="name">closeOnRangeComplete</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Auto-dismiss when both endpoints set.</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">value / placeholder</td><td>soma</td><td class="default">—</td><td>Bindable <code>TimeRange</code>.</td></tr>
<tr><td class="name">open</td><td>soma</td><td class="default">false</td><td>Bindable popover state.</td></tr>
<tr><td class="name">mode</td><td>soma</td><td class="default">inline</td><td><code>inline</code> closes on outside-click; <code>modal</code> requires footer action.</td></tr>
<tr><td class="name">closeOnRangeComplete</td><td>soma</td><td class="default">false</td><td>Auto-close once both endpoints are set.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default">—</td><td>Validation bounds applied to both endpoints.</td></tr>
<tr><td class="name">granularity</td><td>soma</td><td class="default">minute</td><td>Hour / minute / second.</td></tr>
<tr><td class="name">hourCycle</td><td>soma</td><td class="default">locale</td><td>12h adds dayPeriod toggle per endpoint.</td></tr>
<tr><td class="name">start / endReadonlySegments</td><td>soma</td><td class="default">[]</td><td>Lock segments per endpoint.</td></tr>
<tr><td class="name">size / variant / color</td><td>eidos</td><td class="default">md / surface / primary</td><td>Visual-only data attrs.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<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.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
</h2>
<p data-uix-section-desc>
TimeRangePicker declares open / dismiss / commit / clear events on its own surface.
The underlying TimeRangeField and Popover contribute their own events too.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Owner</th><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each eventRows as row}
<tr>
<td class="default">{row.owner}</td>
<td class="name">{row.action.name}</td>
<td class="type">{row.action.semantic.family}</td>
<td>{row.action.semantic.verb ?? '—'}</td>
<td>{row.action.semantic.sequence ?? 'pre'}</td>
<td>{row.action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const selector =
row.owner === 'TimeRangePicker'
? '[data-time-range-picker]'
: row.owner === 'TimeRangeField'
? '[data-time-range-field]'
: '[data-popover-content]';
const target = stageRef?.querySelector(
selector
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: row.action.name,
family: row.action.semantic.family,
target
});
}}>▶ play</button
>
</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>
Selectors live in
<code>src/uix/eidos/components/time-range-picker/time-range-picker.css</code> and reuse
the time-field tokens for the field portion. The clock body lays out two endpoint
columns (start / end) and inherits slider chrome from the slider recipe.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-time-range-picker]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Root visual scope + size/variant/color tokens.</td></tr>
<tr><td class="name"><code>[data-time-range-picker-trigger]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Open-clock button. Has embedded variant inside the input.</td></tr>
<tr><td class="name"><code>[data-time-range-picker-clock][data-state]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Popover dialog surface. 2-column endpoint grid.</td></tr>
<tr><td class="name"><code>[data-time-range-picker-hour-slider][data-endpoint]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Per-endpoint slider wrapper. Composes generic Slider chrome.</td></tr>
<tr><td class="name"><code>[data-picker-footer][data-mode]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Footer action row shared via picker-shell.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Dialog</td><td>Clock exposes <code>role="dialog"</code> with an accessible name.</td></tr>
<tr><td class="name">Trigger</td><td><code>aria-haspopup="dialog"</code> + <code>aria-expanded</code> + <code>aria-controls</code>.</td></tr>
<tr><td class="name">Segments</td><td>Editable segments expose <code>role="spinbutton"</code> with min/max/value per endpoint.</td></tr>
<tr><td class="name">Sliders</td><td>Native slider semantics — keyboard arrow/page/home/end per endpoint.</td></tr>
<tr><td class="name">Modal</td><td><code>mode='modal'</code> blocks outside-click + Escape; consumer must wire Close/Cancel.</td></tr>
<tr><td class="name">Form</td><td>Hidden inputs submit ISO strings for both endpoints when <code>name</code> is set.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.