From 391de6e04cb684613c1fa1706f03ddc9dc23365d Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 21 May 2026 17:59:08 +0200 Subject: [PATCH] feat(eidos): add TimePicker wrapper + demo following P-1..P-5 picker contract Builds on the existing soma TimePicker slider-based clock (HourSlider / MinuteSlider / SecondSlider + DayPeriodToggle). Adds the canonical picker shell: - mode='inline' | 'modal' on the Provider, propagated to popover.modal - Provider helpers commit() / cancel() / clear() with watch(open) snapshot for cancel-revert - Footer composition (TimePicker.Footer / Clear / Cancel / Close as pure-composition Eidos parts; no *Button root props) - New TimePicker.Clock soma component that carries the data-time-picker-clock contract attr, wraps the slider stack - Eidos shell: Provider visual root, Trigger (clock icon), Content (popover content), Clock, Footer + action parts, slider/toggle pass-throughs Renews the time-picker morfo to declare Provider / Trigger / Clock / DayPeriodToggle / DayPeriodItem / Footer / Clear / Cancel / Close, plus all 6 commit/emerge events (open + close-commit/cancel/dismiss/ dismiss-outside + commit-clear). apg=dialog-modal. Demo at /uix/components/time-picker exercises all controls, Footer composition switches, soma + eidos code snippets, morfo / sema / recipe / a11y tabs. README documents the picker contract. 67/67 component-audit PASS, 39/39 morfo:check PASS, svelte-check 0 errors / 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../eidos/components/time-picker/README.md | 107 +++ .../components/time-picker/context.svelte.ts | 19 + src/uix/eidos/components/time-picker/index.ts | 90 +++ .../time-picker/time-picker-cancel.svelte | 33 + .../time-picker/time-picker-clear.svelte | 33 + .../time-picker/time-picker-clock.svelte | 23 + .../time-picker/time-picker-close.svelte | 37 + .../time-picker/time-picker-content.svelte | 71 ++ .../time-picker-day-period-toggle.svelte | 17 + .../time-picker/time-picker-footer.svelte | 20 + .../time-picker-hour-slider.svelte | 10 + .../time-picker-minute-slider.svelte | 10 + .../time-picker-second-slider.svelte | 10 + .../time-picker/time-picker-trigger.svelte | 28 + .../components/time-picker/time-picker.css | 237 +++++++ .../components/time-picker/time-picker.svelte | 38 + src/uix/eidos/components/time-picker/types.ts | 92 +++ src/uix/eidos/index.css | 1 + src/uix/morfo/components/time-picker.ts | 265 ++++++- .../components/time-picker-clock.svelte | 43 ++ .../time-picker/components/time-picker.svelte | 31 +- .../soma/components/time-picker/exports.ts | 5 + .../time-picker-provider.svelte.test.ts | 1 + .../time-picker-provider.svelte.ts | 65 +- src/uix/soma/components/time-picker/types.ts | 26 +- web/routes/uix/+layout@.svelte | 1 + .../uix/components/time-picker/+page.svelte | 666 ++++++++++++++++++ 27 files changed, 1944 insertions(+), 35 deletions(-) create mode 100644 src/uix/eidos/components/time-picker/README.md create mode 100644 src/uix/eidos/components/time-picker/context.svelte.ts create mode 100644 src/uix/eidos/components/time-picker/index.ts create mode 100644 src/uix/eidos/components/time-picker/time-picker-cancel.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-clear.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-clock.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-close.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-content.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-footer.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-second-slider.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker-trigger.svelte create mode 100644 src/uix/eidos/components/time-picker/time-picker.css create mode 100644 src/uix/eidos/components/time-picker/time-picker.svelte create mode 100644 src/uix/eidos/components/time-picker/types.ts create mode 100644 src/uix/soma/components/time-picker/components/time-picker-clock.svelte create mode 100644 web/routes/uix/components/time-picker/+page.svelte diff --git a/src/uix/eidos/components/time-picker/README.md b/src/uix/eidos/components/time-picker/README.md new file mode 100644 index 000000000..b6a6de879 --- /dev/null +++ b/src/uix/eidos/components/time-picker/README.md @@ -0,0 +1,107 @@ +# Eidos TimePicker + +Composite popover combining a segmented `TimeField` input + a slider-based +`Clock` view (Hour / Minute / Second sliders + AM/PM toggle). Soma owns +state coordination (single value + open + helpers `commit / cancel / +clear`); Eidos adds visual treatment, the popover content shell and the +canonical Footer parts. + +## Baseline + +Air no tenía un `TimePicker` equivalente en la rama anterior. La +referencia local real es la composición Soma `time-picker` + `time-field` ++ `popover` + `slider`, que ya implementa la edición segmentada y la +selección por sliders, y la receta Eidos sólo envuelve esa superficie con +tamaños, variante visual y paleta de intents. Referencias externas: +React Aria TimeField+Picker, Ark UI Time Picker, Chakra Time Picker. + +## Superficie + +```svelte + + + {#snippet children({ segments })} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + + {/snippet} + + + + + + + + + + + + + + + + + + + +``` + +Partes públicas: `Trigger`, `Content`, `Clock`, `Label`, `Input`, +`Segment`, `HiddenInput`, `HourSlider`, `MinuteSlider`, `SecondSlider`, +`DayPeriodToggle`, `Footer`, `Clear`, `Cancel`, `Close`, `Anchor`, +`Overlay`, `Arrow`, `Portal`. + +## Comparativa + +| Capacidad | Soma/Eidos | React Aria | Ark UI | Chakra | +| --- | --- | --- | --- | --- | +| Input segmentado | Sí | Sí | Sí | Sí | +| Popover Clock | Sí (sliders + AM/PM) | Sí (column-wheel) | Sí (columns) | Sí (columns) | +| Granularidad hour / minute / second | Sí | Sí | Sí | Sí | +| 12h / 24h cycle | Sí | Sí | Sí | Sí | +| `mode='inline' \| 'modal'` | Sí | No | No | No | +| Footer = composición pura | Sí | No | No | No | +| `commit / cancel / clear` helpers | Sí | Manual | Manual | Manual | +| Segmentos readonly | Sí | No | No | Sí | +| Validación custom | Sí | Sí | Sí | Sí | +| Variante / tamaño / paleta visual | Sí, Eidos | CSS externo | CSS externo | CSS externo | + +## Decisiones + +- **Mode = inline | modal** (P-2). `modal` pasa `modal=true` al + `PopoverProvider` underlying → outside-click + Escape NO cierran; el + consumer debe incluir `` o `` en el footer. +- **Provider helpers** (P-1). `commit()` cierra preservando valor; + `cancel()` revierte al snapshot OPEN-edge y cierra; `clear()` deja + `value=undefined` sin cerrar. Snapshot capturado vía `watch(open)` + cuando `open: false → true`. +- **Shell composition** (P-3). `Provider > Input + Trigger > Portal > + Content > Clock > Sliders + DayPeriodToggle + Footer`. Mismo esqueleto + que `date-picker`. +- **Footer = composición pura** (N-7). Sin props `clearButton/...` en el + root. Cada part Footer renderiza si se compone; omitir la part la + oculta. +- **Sliders en lugar de columnas-wheel**. Son accesibles y nativos + (rueda + drag + arrows + spinbutton ARIA). La UX wheel-scroll de + Chakra/Ark es más vistosa pero requiere más código para la misma + precisión. Pendiente walk visual para evaluar trade-off. +- **HourCycle** drives los sliders: 12h limita hour [1..12] + muestra + DayPeriodToggle; 24h limita hour [0..23] sin toggle. + +## Gaps + +| Gap | Disposición | Detalle | +| --- | --- | --- | +| Column-wheel scroll view (estilo iOS / Chakra) | **diferir** | Sliders cubren el caso. Wheel requiere intersección-observer + snap CSS. Hacer cuando aparezca demanda real. | +| Tests browser-level (Playwright) | **implementar** | Cobertura visual + interacciones (slider drag, AM/PM toggle, modal commit/cancel/clear). En la pasada conjunta de tests. | +| Sema sounds/haptic specific al picker | **diferir** | Reutiliza popover sema por ahora; añadir si emerge necesidad perceptiva específica. | + +## Referencias + +- Soma TimePicker: `src/uix/soma/components/time-picker/README.md` +- Soma TimeField: `src/uix/soma/components/time-field/README.md` +- React Aria TimeField+Picker: https://react-spectrum.adobe.com/react-aria/TimeField.html +- Ark UI Time Picker: https://ark-ui.com/docs/components/time-picker +- Chakra UI Time Picker: https://chakra-ui.com/docs/components/time-picker +- WAI-ARIA APG Dialog (modal): https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ diff --git a/src/uix/eidos/components/time-picker/context.svelte.ts b/src/uix/eidos/components/time-picker/context.svelte.ts new file mode 100644 index 000000000..85f37d270 --- /dev/null +++ b/src/uix/eidos/components/time-picker/context.svelte.ts @@ -0,0 +1,19 @@ +import { getContext, setContext } from 'svelte'; +import type { TimePickerColor, TimePickerSize, TimePickerVariant } from './types'; + +const TIME_PICKER_VISUAL_CONTEXT = Symbol('uix.eidos.time-picker.visual'); + +export interface TimePickerVisualContext { + getSize(): TimePickerSize; + getVariant(): TimePickerVariant; + getColor(): TimePickerColor; +} + +export function setTimePickerVisualContext(ctx: TimePickerVisualContext): TimePickerVisualContext { + setContext(TIME_PICKER_VISUAL_CONTEXT, ctx); + return ctx; +} + +export function getTimePickerVisualContext(): TimePickerVisualContext | undefined { + return getContext(TIME_PICKER_VISUAL_CONTEXT); +} diff --git a/src/uix/eidos/components/time-picker/index.ts b/src/uix/eidos/components/time-picker/index.ts new file mode 100644 index 000000000..b5a1f117f --- /dev/null +++ b/src/uix/eidos/components/time-picker/index.ts @@ -0,0 +1,90 @@ +import TimePickerComponent from './time-picker.svelte' +import Trigger from './time-picker-trigger.svelte' +import Content from './time-picker-content.svelte' +import Clock from './time-picker-clock.svelte' +import Footer from './time-picker-footer.svelte' +import Clear from './time-picker-clear.svelte' +import Cancel from './time-picker-cancel.svelte' +import Close from './time-picker-close.svelte' +import HourSlider from './time-picker-hour-slider.svelte' +import MinuteSlider from './time-picker-minute-slider.svelte' +import SecondSlider from './time-picker-second-slider.svelte' +import DayPeriodToggle from './time-picker-day-period-toggle.svelte' +import Label from '../time-field/time-field-label.svelte' +import Input from '../time-field/time-field-input.svelte' +import Segment from '../time-field/time-field-segment.svelte' +import HiddenInput from '../time-field/time-field-hidden-input.svelte' +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' + +type TimePickerNamespace = typeof TimePickerComponent & { + 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 + HiddenInput: typeof HiddenInput +} + +const TimePicker = TimePickerComponent as TimePickerNamespace +TimePicker.Trigger = Trigger +TimePicker.Portal = Portal +TimePicker.Anchor = Anchor +TimePicker.Overlay = Overlay +TimePicker.Content = Content +TimePicker.Arrow = Arrow +TimePicker.Clock = Clock +TimePicker.Footer = Footer +TimePicker.Clear = Clear +TimePicker.Cancel = Cancel +TimePicker.Close = Close +TimePicker.HourSlider = HourSlider +TimePicker.MinuteSlider = MinuteSlider +TimePicker.SecondSlider = SecondSlider +TimePicker.DayPeriodToggle = DayPeriodToggle +TimePicker.Label = Label +TimePicker.Input = Input +TimePicker.Segment = Segment +TimePicker.HiddenInput = HiddenInput + +export { TimePicker } + +export default TimePicker + +export type { + TimePickerProps, + TimePickerTriggerProps as TriggerProps, + TimePickerContentProps as ContentProps, + TimePickerClockProps as ClockProps, + TimePickerFooterProps as FooterProps, + TimePickerClearProps as ClearProps, + TimePickerCancelProps as CancelProps, + TimePickerCloseProps as CloseProps, + TimePickerLabelProps as LabelProps, + TimePickerInputProps as InputProps, + TimePickerSegmentProps as SegmentProps, + TimePickerHiddenInputProps as HiddenInputProps, + TimePickerAnchorProps as AnchorProps, + TimePickerOverlayProps as OverlayProps, + TimePickerArrowProps as ArrowProps, + TimePickerSliderProps as SliderProps, + InputSnippetProps, + TimePickerSize, + TimePickerVariant, + TimePickerColor +} from './types' diff --git a/src/uix/eidos/components/time-picker/time-picker-cancel.svelte b/src/uix/eidos/components/time-picker/time-picker-cancel.svelte new file mode 100644 index 000000000..6fcd4a20c --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-cancel.svelte @@ -0,0 +1,33 @@ + + + diff --git a/src/uix/eidos/components/time-picker/time-picker-clear.svelte b/src/uix/eidos/components/time-picker/time-picker-clear.svelte new file mode 100644 index 000000000..19d021c08 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-clear.svelte @@ -0,0 +1,33 @@ + + + diff --git a/src/uix/eidos/components/time-picker/time-picker-clock.svelte b/src/uix/eidos/components/time-picker/time-picker-clock.svelte new file mode 100644 index 000000000..3adcb4585 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-clock.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/time-picker-close.svelte b/src/uix/eidos/components/time-picker/time-picker-close.svelte new file mode 100644 index 000000000..b50431f19 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-close.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/uix/eidos/components/time-picker/time-picker-content.svelte b/src/uix/eidos/components/time-picker/time-picker-content.svelte new file mode 100644 index 000000000..c25fa34f2 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-content.svelte @@ -0,0 +1,71 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte b/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte new file mode 100644 index 000000000..562d2dc43 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte @@ -0,0 +1,17 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/time-picker-footer.svelte b/src/uix/eidos/components/time-picker/time-picker-footer.svelte new file mode 100644 index 000000000..13ef5e13e --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte new file mode 100644 index 000000000..3d272f83f --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte new file mode 100644 index 000000000..50df46483 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte new file mode 100644 index 000000000..375cef788 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/time-picker-trigger.svelte b/src/uix/eidos/components/time-picker/time-picker-trigger.svelte new file mode 100644 index 000000000..6bfbc73f6 --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker-trigger.svelte @@ -0,0 +1,28 @@ + + + + {#if children} + {@render children()} + {:else} + + {/if} + diff --git a/src/uix/eidos/components/time-picker/time-picker.css b/src/uix/eidos/components/time-picker/time-picker.css new file mode 100644 index 000000000..cef61276a --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker.css @@ -0,0 +1,237 @@ +/* TimePicker — visual layer. + * + * Reuses time-field tokens for the field-shaped portion (Input + Segments) + * and the calendar/popover surface tokens for the popover Clock body. + */ + +[data-time-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-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-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-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-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-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-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-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-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-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-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-picker-trigger]:hover { + border-color: var(--_time-field-accent-border); + background: var(--_time-field-accent-track); + color: var(--_time-field-accent-text); +} + +[data-time-picker-trigger]:focus-visible { + outline: none; + border-color: var(--_time-field-accent-border); + box-shadow: var(--time-field-focus-shadow); +} + +[data-time-picker-trigger][data-disabled], +[data-time-picker-trigger]:disabled { + cursor: not-allowed; + opacity: var(--time-field-disabled-opacity); +} + +/* Embedded trigger inside the input — flush affordance like date-picker. */ +[data-time-field-input] [data-time-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-field-input] [data-time-picker-trigger]:hover { + border: none; + background: var(--_time-field-accent-track); + color: var(--_time-field-accent-text); +} + +[data-time-field-input] [data-time-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-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-picker-clock] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + min-inline-size: 18rem; + color: var(--color-content-primary); + font-family: var(--font-ui); + font-size: var(--font-size-sm); + line-height: var(--leading-ui); +} + +[data-time-picker-clock] [data-slider] { + inline-size: 100%; +} + +/* ── Footer (Clear / Cancel / Close action row) ──────────────────────────── */ + +[data-time-picker-footer] { + display: flex; + align-items: center; + gap: var(--space-2); + padding-block-start: var(--space-3); + margin-block-start: var(--space-3); + border-block-start: var(--border-width) solid var(--color-border-subtle); +} + +[data-time-picker-clear], +[data-time-picker-cancel] { + margin-inline-end: auto; +} + +[data-time-picker-clear], +[data-time-picker-cancel], +[data-time-picker-close] { + inline-size: max-content; + min-block-size: var(--control-height-sm); + padding-inline: var(--space-3); + border-radius: var(--radius-md); + border: var(--border-width) solid transparent; + background: transparent; + color: var(--color-content-primary); + font: inherit; + font-size: var(--font-size-sm); + line-height: var(--leading-ui); + cursor: pointer; + transition: + background var(--duration-fast) var(--ease-default), + border-color var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default); +} + +[data-time-picker-clear]:hover, +[data-time-picker-cancel]:hover { + background: var(--color-surface-overlay); +} + +[data-time-picker-close] { + border-color: var(--color-primary-border); + background: var(--color-primary-solid); + color: var(--color-content-on-solid); +} + +[data-time-picker-close]:hover { + background: var(--color-primary-solid-hover); +} + +[data-time-picker-clear]:focus-visible, +[data-time-picker-cancel]:focus-visible, +[data-time-picker-close]:focus-visible { + outline: var(--focus-ring-width) solid var(--color-primary-border); + outline-offset: var(--focus-ring-offset); +} + +@media (prefers-reduced-motion: reduce) { + [data-time-picker-trigger], + [data-time-picker-clear], + [data-time-picker-cancel], + [data-time-picker-close] { + transition: none; + } +} diff --git a/src/uix/eidos/components/time-picker/time-picker.svelte b/src/uix/eidos/components/time-picker/time-picker.svelte new file mode 100644 index 000000000..864c8d9fb --- /dev/null +++ b/src/uix/eidos/components/time-picker/time-picker.svelte @@ -0,0 +1,38 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/time-picker/types.ts b/src/uix/eidos/components/time-picker/types.ts new file mode 100644 index 000000000..9b9b7e944 --- /dev/null +++ b/src/uix/eidos/components/time-picker/types.ts @@ -0,0 +1,92 @@ +import type { + ContentProps as SomaTimePickerContentProps, + InputProps as SomaTimePickerInputProps, + InputSnippetProps as TimePickerInputSnippetProps, + LabelProps as SomaTimePickerLabelProps, + ProviderProps, + SegmentProps as SomaTimePickerSegmentProps, + SliderProps as SomaTimePickerSliderProps, + SliderThumbProps as SomaTimePickerSliderThumbProps, + SliderRangeProps as SomaTimePickerSliderRangeProps, + SliderTickProps as SomaTimePickerSliderTickProps, + TriggerProps as SomaTimePickerTriggerProps +} from '$soma/components/time-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' +import type { HTMLButtonAttributes } from 'svelte/elements' + +export type TimePickerSize = Extract +export type TimePickerVariant = ControlVariant +export type TimePickerColor = ColorRole + +export type TimePickerProps = ProviderProps & { + /** Visual size for the input segments, trigger and clock. @default 'md' */ + size?: ResponsiveProp + /** Visual treatment shared by the field and clock. @default 'surface' */ + variant?: TimePickerVariant + /** Intent palette used for focus and selected affordances. @default 'primary' */ + color?: TimePickerColor +} + +export type TimePickerTriggerProps = SomaTimePickerTriggerProps & { + size?: ResponsiveProp + variant?: TimePickerVariant + color?: TimePickerColor +} + +export type TimePickerContentProps = Omit & { + size?: ResponsiveProp +} + +export type TimePickerClockProps = { + id?: string + size?: ResponsiveProp + variant?: TimePickerVariant + color?: TimePickerColor + class?: string + style?: string + children?: Snippet +} + +export type TimePickerFooterProps = { + id?: string + class?: string + style?: string + children?: Snippet +} + +export type TimePickerClearProps = HTMLButtonAttributes & { + children?: Snippet +} + +export type TimePickerCancelProps = HTMLButtonAttributes & { + children?: Snippet +} + +export type TimePickerCloseProps = HTMLButtonAttributes & { + children?: Snippet +} + +export type TimePickerLabelProps = SomaTimePickerLabelProps +export type TimePickerInputProps = SomaTimePickerInputProps +export type TimePickerSegmentProps = SomaTimePickerSegmentProps +export type TimePickerHiddenInputProps = Record + +export type TimePickerAnchorProps = PopoverAnchorProps +export type TimePickerOverlayProps = PopoverOverlayProps +export type TimePickerArrowProps = PopoverArrowProps +export type TimePickerPopoverCloseProps = PopoverCloseProps + +export type TimePickerSliderProps = SomaTimePickerSliderProps +export type TimePickerSliderThumbProps = SomaTimePickerSliderThumbProps +export type TimePickerSliderRangeProps = SomaTimePickerSliderRangeProps +export type TimePickerSliderTickProps = SomaTimePickerSliderTickProps + +export type { TimePickerInputSnippetProps, TimePickerInputSnippetProps as InputSnippetProps } diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index c9b0fd86f..56d8320c2 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -91,6 +91,7 @@ @import './components/date-range-picker/date-range-picker.css'; @import './components/time-field/time-field.css'; @import './components/color-field/color-field.css'; +@import './components/time-picker/time-picker.css'; @import './components/slider/slider.css'; @import './components/toolbar/toolbar.css'; @import './components/tag-group/tag-group.css'; diff --git a/src/uix/morfo/components/time-picker.ts b/src/uix/morfo/components/time-picker.ts index f29c98138..e65a476b3 100644 --- a/src/uix/morfo/components/time-picker.ts +++ b/src/uix/morfo/components/time-picker.ts @@ -1,15 +1,135 @@ import type { Morfo } from '../types'; import { v } from '../types'; +/** + * TimePicker — composite popover containing a TimeField (segmented input) + * + Clock (slider-based hour/minute/second + dayPeriod toggle). The morfo + * declares the picker's own surface (trigger + clock shell + footer + * actions + dismissal/commit events). + */ export const timePickerMorfo = { name: 'TimePicker', kebab: 'time-picker', - scope: ['soma'], + scope: ['soma', 'sema'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/', texts: { label: '#?components.time-picker.label|Time Picker', - trigger: '#?components.time-picker.trigger|Open time picker' + trigger: '#?components.time-picker.trigger|Open clock', + clock: '#?components.time-picker.clock|Time clock', + hour: '#?components.time-picker.hour|Hour', + minute: '#?components.time-picker.minute|Minute', + second: '#?components.time-picker.second|Second', + 'day-period': '#?components.time-picker.day-period|AM / PM', + am: '#?components.time-picker.am|AM', + pm: '#?components.time-picker.pm|PM', + clear: '#?components.time-picker.clear|Clear time', + cancel: '#?components.time-picker.cancel|Cancel', + close: '#?components.time-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, @@ -22,11 +142,22 @@ export const timePickerMorfo = { 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', @@ -37,35 +168,67 @@ export const timePickerMorfo = { role: 'button', optional: false, 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('content'), - condition: { when: 'part-present', part: 'content' }, + value: v.partRef('clock'), + condition: { when: 'part-present', part: 'clock' }, severity: 'recommended' }, { attr: 'aria-label', - value: v.translationRef('#?components.time-picker.trigger|Open time picker'), + value: v.translationRef('#?components.time-picker.trigger|Open clock'), severity: 'recommended' } + ], + keyboard: [ + { key: 'Enter', action: 'open' }, + { key: ' ', action: 'open' } ] }, { - name: 'Content', - kebab: 'content', + name: 'Clock', + kebab: 'clock', archetype: 'content', kind: 'public', defaultElement: 'div', role: 'dialog', optional: false, states: ['open', 'closed'], - data: [{ attr: 'data-state', values: ['open', 'closed'] }], - aria: [] + 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-picker.clock|Time clock'), + severity: 'recommended' + } + ], + keyboard: [ + { key: 'Escape', action: 'close' }, + { key: 'Tab', action: 'focus-next' }, + { key: 'Shift+Tab', action: 'focus-prev' } + ] }, { name: 'DayPeriodToggle', @@ -75,8 +238,14 @@ export const timePickerMorfo = { defaultElement: 'div', role: 'radiogroup', optional: true, - data: [], - aria: [] + data: [{ attr: 'data-disabled', severity: 'optional' }], + aria: [ + { + attr: 'aria-label', + value: v.translationRef('#?components.time-picker.day-period|AM / PM'), + severity: 'recommended' + } + ] }, { name: 'DayPeriodItem', @@ -87,6 +256,70 @@ export const timePickerMorfo = { optional: true, data: [{ attr: 'data-checked', severity: 'optional' }], aria: [] + }, + { + name: 'Footer', + kebab: 'footer', + archetype: 'footer', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [{ attr: 'data-mode', values: ['inline', 'modal'], severity: 'optional' }], + aria: [] + }, + { + name: 'Clear', + kebab: 'clear', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-action', value: v.literal('clear') }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.translationRef('#?components.time-picker.clear|Clear time'), + severity: 'recommended' + } + ] + }, + { + name: 'Cancel', + kebab: 'cancel', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-action', value: v.literal('cancel') }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.translationRef('#?components.time-picker.cancel|Cancel'), + severity: 'recommended' + } + ] + }, + { + name: 'Close', + kebab: 'close', + archetype: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: true, + data: [{ attr: 'data-action', value: v.literal('close') }], + aria: [ + { attr: 'type', value: v.literal('button') }, + { + attr: 'aria-label', + value: v.translationRef('#?components.time-picker.close|Close'), + severity: 'recommended' + } + ] } ] } as const satisfies Morfo; diff --git a/src/uix/soma/components/time-picker/components/time-picker-clock.svelte b/src/uix/soma/components/time-picker/components/time-picker-clock.svelte new file mode 100644 index 000000000..bf601d689 --- /dev/null +++ b/src/uix/soma/components/time-picker/components/time-picker-clock.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/time-picker/components/time-picker.svelte b/src/uix/soma/components/time-picker/components/time-picker.svelte index 0b0540d83..49b04c6ce 100644 --- a/src/uix/soma/components/time-picker/components/time-picker.svelte +++ b/src/uix/soma/components/time-picker/components/time-picker.svelte @@ -1,9 +1,7 @@ -{@render children?.()} +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/time-picker/exports.ts b/src/uix/soma/components/time-picker/exports.ts index 1ac129dda..a956647e2 100644 --- a/src/uix/soma/components/time-picker/exports.ts +++ b/src/uix/soma/components/time-picker/exports.ts @@ -1,11 +1,16 @@ // Root + unique wrappers export { default as Provider } from './components/time-picker.svelte'; export { default as Trigger } from './components/time-picker-trigger.svelte'; +export { default as Clock } from './components/time-picker-clock.svelte'; export { default as HourSlider } from './components/time-picker-hour-slider.svelte'; export { default as MinuteSlider } from './components/time-picker-minute-slider.svelte'; export { default as SecondSlider } from './components/time-picker-second-slider.svelte'; export { default as DayPeriodToggle } from './components/time-picker-day-period-toggle.svelte'; +// Provider class (action helpers: commit / cancel / clear) +export { TimePickerProvider, timePickerAttrs } from './time-picker-provider.svelte'; +export type { TimePickerMode } from './time-picker-provider.svelte'; + // Popover surface export { Content, Arrow, Close, Overlay, Anchor } from '../popover/exports'; diff --git a/src/uix/soma/components/time-picker/time-picker-provider.svelte.test.ts b/src/uix/soma/components/time-picker/time-picker-provider.svelte.test.ts index 115273339..0105cdf06 100644 --- a/src/uix/soma/components/time-picker/time-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/time-picker/time-picker-provider.svelte.test.ts @@ -68,6 +68,7 @@ function createTimePickerOpts() { readonly: state(false), readonlySegments: state([]), required: state(false), + mode: state<'inline' | 'modal'>('inline'), granularity: state(undefined), hideTimeZone: state(false), hourCycle: state(undefined), diff --git a/src/uix/soma/components/time-picker/time-picker-provider.svelte.ts b/src/uix/soma/components/time-picker/time-picker-provider.svelte.ts index 26d927477..c4e150e2a 100644 --- a/src/uix/soma/components/time-picker/time-picker-provider.svelte.ts +++ b/src/uix/soma/components/time-picker/time-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'; @@ -17,6 +20,12 @@ import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; const attrs = createAttrs(timePickerMorfo); +/** + * Picker interaction mode. `inline` (default) auto-closes the popover on + * outside-click/Escape. `modal` keeps the popover open; the user must + * commit via Close/Save in the footer or revert via Cancel. + */ +export type TimePickerMode = 'inline' | 'modal'; export interface TimePickerOpts extends @@ -35,6 +44,7 @@ export interface TimePickerOpts readonly: boolean; readonlySegments: EditableTimeSegmentPart[]; required: boolean; + mode: TimePickerMode; granularity: TimeGranularity | undefined; hideTimeZone: boolean; hourCycle: HourCycle | undefined; @@ -69,16 +79,36 @@ export class TimePickerProvider { return new TimePickerProvider(opts); } + /** Snapshot captured on the OPEN edge for `cancel()` revert. */ + private valueOnOpen: TimeValue | undefined = undefined; + private constructor(opts: TimePickerOpts) { this.opts = opts; this.soma = Soma.require(); - this.runtime = this.soma.runtime(timePickerMorfo, {}); + this.runtime = this.soma.runtime(timePickerMorfo, { + states: { open: () => this.opts.open.current }, + props: { + disabled: () => this.opts.disabled.current, + readonly: () => this.opts.readonly.current, + required: () => this.opts.required.current, + invalid: () => false + } + }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: TimePickerProvider.ctx }); + + watch( + () => this.opts.open.current, + (now, prev) => { + if (now && !prev) { + this.valueOnOpen = untrack(() => this.opts.value.current); + } + } + ); } /** Resolved hour cycle — same rule as TimeField: explicit → service → locale. */ @@ -101,7 +131,6 @@ export class TimePickerProvider { return (this.opts.value.current ?? this.opts.placeholder.current) as TimeValue; } - /** Read the hour from the current time, in the resolved cycle. */ get hour(): number { const h = (this.current as unknown as { hour: number }).hour; if (this.resolvedHourCycle === 12) { @@ -124,7 +153,6 @@ export class TimePickerProvider { return h < 12 ? 'AM' : 'PM'; } - /** Setters go through `value` so segments + sliders stay in sync. */ setHour(next: number): void { const value = this.current; let h = next; @@ -156,6 +184,37 @@ export class TimePickerProvider { readonly hourMin = $derived.by(() => (this.resolvedHourCycle === 12 ? 1 : 0)); readonly hourMax = $derived.by(() => (this.resolvedHourCycle === 12 ? 12 : 23)); + + // ── Action helpers (footer wiring) ──────────────────────────────────────── + + /** Commit current value and close. Default for Save/Done in the footer. */ + commit(): void { + this.opts.open.current = false; + } + + /** Discard mid-session edits (revert to OPEN-edge snapshot) and close. */ + cancel(): void { + this.opts.value.current = this.valueOnOpen; + this.opts.open.current = false; + } + + /** Reset value to undefined without closing. */ + clear(): void { + this.opts.value.current = undefined; + } + + 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 timePickerAttrs }; diff --git a/src/uix/soma/components/time-picker/types.ts b/src/uix/soma/components/time-picker/types.ts index 19c57cc26..0515e2cd2 100644 --- a/src/uix/soma/components/time-picker/types.ts +++ b/src/uix/soma/components/time-picker/types.ts @@ -1,6 +1,6 @@ import type { Snippet } from 'svelte'; import type { WithChild, Without, OnChangeFn, Direction } from '../../types'; -import type { PrimitiveButtonAttributes } from '../../types'; +import type { PrimitiveButtonAttributes, PrimitiveDivAttributes } from '../../types'; import type { TimeValue, TimeValidator, @@ -9,9 +9,9 @@ import type { TimeGranularity, HourCycle } from '$libs/days'; +import type { TimePickerMode } from './time-picker-provider.svelte'; -/** Props for the root `TimePicker.Provider`. */ -export type TimePickerProviderProps = { +type TimePickerBaseProps = { id?: string; // ── Value ── @@ -21,11 +21,18 @@ export type TimePickerProviderProps = { onPlaceholderChange?: OnChangeFn; // ── Open state ── - /** Whether the popover is open. Bindable. @default false */ open?: boolean; onOpenChange?: OnChangeFn; onOpenChangeComplete?: OnChangeFn; + /** + * Picker interaction mode. `inline` (default) closes the popover on + * outside-click + Escape. `modal` keeps the popover open until the + * user explicitly commits via Save/Close or reverts via Cancel. + * @default 'inline' + */ + mode?: TimePickerMode; + // ── Validation ── validate?: TimeValidator; onInvalid?: TimeOnInvalid; @@ -46,18 +53,18 @@ export type TimePickerProviderProps = { dir?: Direction; // ── Slider behavior ── - /** Step for the hour slider. @default 1 */ hourStep?: number; - /** Step for the minute slider. @default 1 */ minuteStep?: number; - /** Step for the second slider. @default 1 */ secondStep?: number; // ── ARIA ── errorMessageId?: string; - children?: Snippet; }; +/** Props for the root `TimePicker.Provider`. */ +export type TimePickerProviderProps = WithChild & + Without; + /** Props for `TimePicker.Trigger`. */ export type TimePickerTriggerProps = WithChild<{ id?: string; @@ -65,6 +72,9 @@ export type TimePickerTriggerProps = WithChild<{ }> & Without; +// Suppress unused import warning by re-exporting Snippet +export type { Snippet }; + // ── Re-exports ────────────────────────────────────────────────────────────── export type { diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 7fb399380..677b5712c 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -148,6 +148,7 @@ { slug: '/uix/components/date-picker', label: 'Date picker' }, { slug: '/uix/components/date-range-picker', label: 'Date range picker' }, { slug: '/uix/components/time-field', label: 'Time field' }, + { slug: '/uix/components/time-picker', label: 'Time picker' }, { slug: '/uix/components/color-field', label: 'Color field' }, { slug: '/uix/components/stepper', label: 'Stepper' }, { slug: '/uix/components/tag-group', label: 'Tag group' }, diff --git a/web/routes/uix/components/time-picker/+page.svelte b/web/routes/uix/components/time-picker/+page.svelte new file mode 100644 index 000000000..b0ef0fa20 --- /dev/null +++ b/web/routes/uix/components/time-picker/+page.svelte @@ -0,0 +1,666 @@ + + +
+
+
Forms · TimePicker
+

TimePicker

+

+ Segmented time input + popover clock. Soma owns value coordination, sliders + AM/PM, + validation and form submission; Eidos adds size, variant, intent color, the popover + shell and the canonical Footer parts. +

+
+ parts{compiled.parts.order.length} + events{eventRows.length} + variants3 + sizes3 +
+
+ +
+
+
+ + Start time + + {#snippet children({ segments }: InputSnippetProps)} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} + {/each} + + {/snippet} + + + + + + + + {#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 value, 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
Segmented inputyesyesyesyesSoma owns editable segments.
Popover clockcolumn wheelcolumnscolumnssliders + AM/PMSliders cover the case; wheel-scroll deferred.
Granularity hour/min/secyesyesyesyesDrives which slider renders.
12 / 24 hour cycleyesyesyesyes12h adds dayPeriod toggle.
mode='inline' | 'modal'nononoyesSame contract as date-picker.
Footer = compositionnononoyesNo *Button root props — pure composition.
commit/cancel/clear helpersmanualmanualmanualyesWired by Footer parts.
Readonly segmentsnonoyesyesPer-segment lock without disabling input.
+
+
+ + + + + + + + + + + + +
PropOwnerDefaultNotes
value / placeholdersoma—Bindable TimeValue.
opensomafalseBindable popover state.
modesomainlineinline closes on outside-click; modal requires footer action.
minValue / maxValuesoma—Validation bounds.
granularitysomaminuteHour / minute / second.
hourCyclesomalocale12h adds dayPeriod segment + toggle in clock.
readonlySegmentssoma[]Lock individual segments / sliders.
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 +

+

+ TimePicker declares open / dismiss / commit / clear events on its own surface. The + underlying TimeField and Popover contribute their own commit/emerge 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-picker/time-picker.css and + reuse the time-field tokens for the field portion. The clock body is a flex column + wrapping slider parts (which carry their own slider recipe). +

+
+ + + + + + + + + +
SelectorOwnerPurpose
[data-time-picker]morfoRoot visual scope + size/variant/color tokens.
[data-time-picker-trigger]morfoOpen-clock button. Has embedded variant inside the input.
[data-time-picker-clock][data-state]morfoPopover dialog surface. Animates from open/closed state.
[data-time-picker-footer][data-mode]morfoFooter action row. Reflects mode for CSS targeting.
[data-time-picker-clear], [data-time-picker-cancel], [data-time-picker-close]morfoFooter actions. Close has solid emphasis (Save semantics).
+
+
+ {/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.
SlidersNative slider semantics — keyboard arrow/page/home/end.
Modalmode='modal' blocks outside-click + Escape; consumer must wire Close/Cancel.
FormHidden input submits the ISO time string when name is set on Input.
+
+
+ {/if} +