From dd22cfc2bc34a9c55b1641d83311bf1971575529 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 00:05:40 +0200 Subject: [PATCH] feat(time-range-picker): eidos wrapper + canonical picker-shell parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/time-range-picker/README.md | 98 +++ .../time-range-picker/context.svelte.ts | 32 + .../components/time-range-picker/index.ts | 93 +++ .../time-range-picker-clock.svelte | 23 + .../time-range-picker-content.svelte | 71 ++ ...time-range-picker-day-period-toggle.svelte | 10 + .../time-range-picker-hour-slider.svelte | 61 ++ .../time-range-picker-minute-slider.svelte | 49 ++ .../time-range-picker-second-slider.svelte | 49 ++ .../time-range-picker-trigger.svelte | 28 + .../time-range-picker/time-range-picker.css | 320 ++++++++ .../time-range-picker.svelte | 40 + .../components/time-range-picker/types.ts | 90 +++ src/uix/eidos/index.css | 1 + src/uix/morfo/components/color-picker.ts | 108 ++- src/uix/morfo/components/time-range-picker.ts | 233 +++++- .../color-picker-provider.svelte.ts | 10 +- .../components/time-range-picker-clock.svelte | 46 ++ .../components/time-range-picker.svelte | 34 +- .../components/time-range-picker/exports.ts | 7 + .../time-range-picker-provider.svelte.test.ts | 1 + .../time-range-picker-provider.svelte.ts | 88 ++- .../components/time-range-picker/types.ts | 17 +- web/routes/uix/+layout@.svelte | 1 + .../components/time-range-picker/+page.svelte | 743 ++++++++++++++++++ 25 files changed, 2219 insertions(+), 34 deletions(-) create mode 100644 src/uix/eidos/components/time-range-picker/README.md create mode 100644 src/uix/eidos/components/time-range-picker/context.svelte.ts create mode 100644 src/uix/eidos/components/time-range-picker/index.ts create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-clock.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-content.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-day-period-toggle.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-hour-slider.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-minute-slider.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-second-slider.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker-trigger.svelte create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker.css create mode 100644 src/uix/eidos/components/time-range-picker/time-range-picker.svelte create mode 100644 src/uix/eidos/components/time-range-picker/types.ts create mode 100644 src/uix/soma/components/time-range-picker/components/time-range-picker-clock.svelte create mode 100644 web/routes/uix/components/time-range-picker/+page.svelte diff --git a/src/uix/eidos/components/time-range-picker/README.md b/src/uix/eidos/components/time-range-picker/README.md new file mode 100644 index 000000000..71b7a4ade --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/README.md @@ -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 + + + {#snippet children({ segments })} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + {/snippet} + + + {#snippet children({ segments })} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + + {/snippet} + + + + + +
+ Start + + + +
+
+ End + + + +
+ + + + + + +
+
+
+
+``` + +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. diff --git a/src/uix/eidos/components/time-range-picker/context.svelte.ts b/src/uix/eidos/components/time-range-picker/context.svelte.ts new file mode 100644 index 000000000..5f420b1de --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/context.svelte.ts @@ -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 `` 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(TIME_RANGE_PICKER_VISUAL_CONTEXT); +} diff --git a/src/uix/eidos/components/time-range-picker/index.ts b/src/uix/eidos/components/time-range-picker/index.ts new file mode 100644 index 000000000..0d3fbba4e --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/index.ts @@ -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'; diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-clock.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-clock.svelte new file mode 100644 index 000000000..2d7b18b17 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-clock.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-content.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-content.svelte new file mode 100644 index 000000000..2fe3ea991 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-content.svelte @@ -0,0 +1,71 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-day-period-toggle.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-day-period-toggle.svelte new file mode 100644 index 000000000..11ea3a516 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-day-period-toggle.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-hour-slider.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-hour-slider.svelte new file mode 100644 index 000000000..941730be0 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-hour-slider.svelte @@ -0,0 +1,61 @@ + + +
+ {#if showRuler} + + {/if} + + {#if children} + {@render children?.()} + {:else} + + + {/if} + +
diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-minute-slider.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-minute-slider.svelte new file mode 100644 index 000000000..9d7c7cb31 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-minute-slider.svelte @@ -0,0 +1,49 @@ + + +
+ {#if showRuler} + + {/if} + + {#if children} + {@render children?.()} + {:else} + + + {/if} + +
diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-second-slider.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-second-slider.svelte new file mode 100644 index 000000000..5e755a333 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-second-slider.svelte @@ -0,0 +1,49 @@ + + +
+ {#if showRuler} + + {/if} + + {#if children} + {@render children?.()} + {:else} + + + {/if} + +
diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker-trigger.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker-trigger.svelte new file mode 100644 index 000000000..758cafffd --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker-trigger.svelte @@ -0,0 +1,28 @@ + + + + {#if children} + {@render children()} + {:else} + + {/if} + diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker.css b/src/uix/eidos/components/time-range-picker/time-range-picker.css new file mode 100644 index 000000000..5554bfb59 --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker.css @@ -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; + } +} diff --git a/src/uix/eidos/components/time-range-picker/time-range-picker.svelte b/src/uix/eidos/components/time-range-picker/time-range-picker.svelte new file mode 100644 index 000000000..de9a940ea --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/time-range-picker.svelte @@ -0,0 +1,40 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-range-picker/types.ts b/src/uix/eidos/components/time-range-picker/types.ts new file mode 100644 index 000000000..31122415b --- /dev/null +++ b/src/uix/eidos/components/time-range-picker/types.ts @@ -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; +export type TimeRangePickerVariant = ControlVariant; +export type TimeRangePickerColor = ColorRole; + +export type TimeRangePickerProps = ProviderProps & { + /** Visual size for input segments, trigger and clock. @default 'md' */ + size?: ResponsiveProp; + /** 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; + variant?: TimeRangePickerVariant; + color?: TimeRangePickerColor; +}; + +export type TimeRangePickerContentProps = Omit & { + size?: ResponsiveProp; +}; + +export type TimeRangePickerClockProps = { + id?: string; + size?: ResponsiveProp; + 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; + +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 +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index bab90e2ab..a6ee62161 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -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'; diff --git a/src/uix/morfo/components/color-picker.ts b/src/uix/morfo/components/color-picker.ts index 3c58b8978..0012addda 100644 --- a/src/uix/morfo/components/color-picker.ts +++ b/src/uix/morfo/components/color-picker.ts @@ -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' }, diff --git a/src/uix/morfo/components/time-range-picker.ts b/src/uix/morfo/components/time-range-picker.ts index b92ed9d6c..9d8cf365f 100644 --- a/src/uix/morfo/components/time-range-picker.ts +++ b/src/uix/morfo/components/time-range-picker.ts @@ -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 `` + // re-exports the `` parts. ] } as const satisfies Morfo; diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts index 35cbe7c2d..1f97c41bd 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts @@ -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, diff --git a/src/uix/soma/components/time-range-picker/components/time-range-picker-clock.svelte b/src/uix/soma/components/time-range-picker/components/time-range-picker-clock.svelte new file mode 100644 index 000000000..34f4c9c50 --- /dev/null +++ b/src/uix/soma/components/time-range-picker/components/time-range-picker-clock.svelte @@ -0,0 +1,46 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/time-range-picker/components/time-range-picker.svelte b/src/uix/soma/components/time-range-picker/components/time-range-picker.svelte index c8ce99d62..137ee893c 100644 --- a/src/uix/soma/components/time-range-picker/components/time-range-picker.svelte +++ b/src/uix/soma/components/time-range-picker/components/time-range-picker.svelte @@ -1,10 +1,11 @@ -{@render children?.()} +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/time-range-picker/exports.ts b/src/uix/soma/components/time-range-picker/exports.ts index 3096bd862..73862e49e 100644 --- a/src/uix/soma/components/time-range-picker/exports.ts +++ b/src/uix/soma/components/time-range-picker/exports.ts @@ -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, diff --git a/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.test.ts b/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.test.ts index 69d6e7596..b70b996a2 100644 --- a/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.test.ts @@ -73,6 +73,7 @@ function createTimeRangePickerOpts() { endReadonlySegments: state(['dayPeriod']), required: state(false), closeOnRangeComplete: state(true), + mode: state<'inline' | 'modal'>('inline'), granularity: state(undefined), hideTimeZone: state(false), hourCycle: state(undefined), diff --git a/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.ts b/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.ts index 89c81f84d..344a732e9 100644 --- a/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.ts +++ b/src/uix/soma/components/time-range-picker/time-range-picker-provider.svelte.ts @@ -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 + * `` 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 }; diff --git a/src/uix/soma/components/time-range-picker/types.ts b/src/uix/soma/components/time-range-picker/types.ts index 787679b8e..9ae6a3084 100644 --- a/src/uix/soma/components/time-range-picker/types.ts +++ b/src/uix/soma/components/time-range-picker/types.ts @@ -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 & + Without; + /** Props for `TimeRangePicker.Trigger`. */ export type TimeRangePickerTriggerProps = WithChild<{ id?: string; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 556b6b7b4..3034f0959 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' }, diff --git a/web/routes/uix/components/time-range-picker/+page.svelte b/web/routes/uix/components/time-range-picker/+page.svelte new file mode 100644 index 000000000..80e268418 --- /dev/null +++ b/web/routes/uix/components/time-range-picker/+page.svelte @@ -0,0 +1,743 @@ + + +
+
+
Forms · TimeRangePicker
+

TimeRangePicker

+

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

+
+ parts{compiled.parts.order.length} + events{eventRows.length} + variants3 + sizes3 +
+
+ +
+
+
+ + Working hours + + {#snippet children({ segments }: InputSnippetProps)} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + {/snippet} + + + {#snippet children({ segments }: InputSnippetProps)} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + + {/snippet} + + + + + +
+ Start + + + {#if granularity === 'second'} + + {/if} + {#if hourCycle === 12} + + {/if} +
+
+ End + + + {#if granularity === 'second'} + + {/if} + {#if hourCycle === 12} + + {/if} +
+ + {#if showFooter && (showClear || showCancel || showClose)} + + {#if showClear}{/if} + {#if showCancel}{/if} + {#if showClose}{/if} + + {/if} +
+
+
+
+
+
+
+ trace + {#if trace.length === 0} + edit, open the clock or play an event to see sema stamps + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent + ? ' · ' + entry.intent + : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value + {valueLabel} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ soma owns the range state machine, segment + editing, validation and the open state. + eidos owns visual treatment. +

+ +
+ soma props · value + behaviour +
+
+ + + + + + + + +
+ +
Footer composition
+

+ Each switch decides whether the part is included in the markup. The parts + never read these flags — omitting the part hides it, including it always renders. +

+
+ +
+ +
+ eidos props · visual treatment +
+
+ + + +
+ +
+
+ soma + headless · provider + sliders + svelte +
+
{somaSnippet}
+
+
+
+ eidos + visual · root + attached parts + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API + reference comparison

+
+ + + + + + + + + + + + + +
FeatureReact AriaArkChakraUIXDecision
Paired segmented inputsyes (Date)nonoyesSoma owns start/end editors.
Per-endpoint slidersnononoyesTwo slider columns; share generic Slider.
Range state machineyes (Date)n/an/ayesEmpty → pending → complete coordinated in soma.
mode='inline' | 'modal'nononoyesSame contract as date / time / color pickers.
Footer = compositionnononoyesNo *Button root props — pure composition.
commit/cancel/clear helpersmanualmanualmanualyesWired by Footer parts via picker-shell context.
closeOnRangeCompletenononoyesAuto-dismiss when both endpoints set.
+
+
+ + + + + + + + + + + + + +
PropOwnerDefaultNotes
value / placeholdersoma—Bindable TimeRange.
opensomafalseBindable popover state.
modesomainlineinline closes on outside-click; modal requires footer action.
closeOnRangeCompletesomafalseAuto-close once both endpoints are set.
minValue / maxValuesoma—Validation bounds applied to both endpoints.
granularitysomaminuteHour / minute / second.
hourCyclesomalocale12h adds dayPeriod toggle per endpoint.
start / endReadonlySegmentssoma[]Lock segments per endpoint.
size / variant / coloreidosmd / surface / primaryVisual-only data attrs.
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+
+ + + + {#each partsList as part} + + + + + + + + {/each} + +
PartMarkerElementRoleOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events + perceptual signature +

+

+ TimeRangePicker declares open / dismiss / commit / clear events on its own surface. + The underlying TimeRangeField and Popover contribute their own events too. +

+
+ + + + {#each eventRows as row} + + + + + + + + + + {/each} + +
OwnerNameFamilyVerbSequenceTargetPlay
{row.owner}{row.action.name}{row.action.semantic.family}{row.action.semantic.verb ?? '—'}{row.action.semantic.sequence ?? 'pre'}{row.action.target} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors live in + src/uix/eidos/components/time-range-picker/time-range-picker.css 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. +

+
+ + + + + + + + + +
SelectorOwnerPurpose
[data-time-range-picker]morfoRoot visual scope + size/variant/color tokens.
[data-time-range-picker-trigger]morfoOpen-clock button. Has embedded variant inside the input.
[data-time-range-picker-clock][data-state]morfoPopover dialog surface. 2-column endpoint grid.
[data-time-range-picker-hour-slider][data-endpoint]morfoPer-endpoint slider wrapper. Composes generic Slider chrome.
[data-picker-footer][data-mode]eidosFooter action row shared via picker-shell.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + +
ConcernContract
DialogClock exposes role="dialog" with an accessible name.
Triggeraria-haspopup="dialog" + aria-expanded + aria-controls.
SegmentsEditable segments expose role="spinbutton" with min/max/value per endpoint.
SlidersNative slider semantics — keyboard arrow/page/home/end per endpoint.
Modalmode='modal' blocks outside-click + Escape; consumer must wire Close/Cancel.
FormHidden inputs submit ISO strings for both endpoints when name is set.
+
+
+ {/if} +