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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent ff279ae828
commit 391de6e04c

@ -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
<TimePicker bind:value bind:open placeholder={defaultTime} mode="inline">
<TimePicker.Input>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimePicker.Segment {part}>{value}</TimePicker.Segment>
{/each}
<TimePicker.Trigger />
{/snippet}
</TimePicker.Input>
<TimePicker.Portal>
<TimePicker.Content>
<TimePicker.Clock>
<TimePicker.HourSlider />
<TimePicker.MinuteSlider />
<TimePicker.SecondSlider />
<TimePicker.DayPeriodToggle />
<TimePicker.Footer>
<TimePicker.Clear />
<TimePicker.Cancel />
<TimePicker.Close />
</TimePicker.Footer>
</TimePicker.Clock>
</TimePicker.Content>
</TimePicker.Portal>
</TimePicker>
```
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 `<Close>` o `<Cancel>` 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/

@ -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<TimePickerVisualContext | undefined>(TIME_PICKER_VISUAL_CONTEXT);
}

@ -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'

@ -0,0 +1,33 @@
<script lang="ts">
import { TimePickerProvider } from '$soma/components/time-picker';
import { getActiveUix } from '$active-uix';
import type { TimePickerCancelProps } from './types';
// Cancel action — reverts to the value snapshot taken on open and
// closes the popover. Renders unconditionally — visibility is the
// consumer's responsibility (include the part to show it).
const { children, onclick, ...rest }: TimePickerCancelProps = $props();
const uix = getActiveUix();
const provider = TimePickerProvider.require();
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.cancel();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-time-picker-cancel
data-action="cancel"
aria-label={uix.langs.ts('#?components.time-picker.cancel|Cancel')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.time-picker.cancel|Cancel')}
{/if}
</button>

@ -0,0 +1,33 @@
<script lang="ts">
import { TimePickerProvider } from '$soma/components/time-picker';
import { getActiveUix } from '$active-uix';
import type { TimePickerClearProps } from './types';
// Clear action — resets the picker's value (undefined) and keeps the
// popover open. Renders unconditionally — visibility is the
// consumer's responsibility (include the part to show it).
const { children, onclick, ...rest }: TimePickerClearProps = $props();
const uix = getActiveUix();
const provider = TimePickerProvider.require();
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.clear();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-time-picker-clear
data-action="clear"
aria-label={uix.langs.ts('#?components.time-picker.clear|Clear time')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.time-picker.clear|Clear time')}
{/if}
</button>

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

@ -0,0 +1,37 @@
<script lang="ts">
import { TimePickerProvider } from '$soma/components/time-picker';
import { getActiveUix } from '$active-uix';
import type { TimePickerCloseProps } from './types';
// Close / Save action — commits the current value and closes the
// popover. Renders unconditionally — visibility is the consumer's
// responsibility (include the part to show it).
//
// In `mode='modal'` the consumer MUST include this part (or another
// route out) because outside-click and Escape are intentionally
// blocked — otherwise the picker has no exit affordance.
const { children, onclick, ...rest }: TimePickerCloseProps = $props();
const uix = getActiveUix();
const provider = TimePickerProvider.require();
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
provider.commit();
if (typeof onclick === 'function') onclick.call(event.currentTarget, event);
}
</script>
<button
type="button"
data-time-picker-close
data-action="close"
aria-label={uix.langs.ts('#?components.time-picker.close|Close')}
onclick={handleClick}
{...rest}
>
{#if children}
{@render children()}
{:else}
{uix.langs.ts('#?components.time-picker.close|Close')}
{/if}
</button>

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

@ -0,0 +1,17 @@
<script lang="ts">
import * as TimePicker from '$soma/components/time-picker';
import type { Snippet } from 'svelte';
type Props = {
id?: string;
children?: Snippet;
class?: string;
style?: string;
};
let { children, ...rest }: Props = $props();
</script>
<TimePicker.DayPeriodToggle {...rest}>
{@render children?.()}
</TimePicker.DayPeriodToggle>

@ -0,0 +1,20 @@
<script lang="ts">
import { TimePickerProvider } from '$soma/components/time-picker';
import type { TimePickerFooterProps } from './types';
// Footer container for the picker clock. Pure composition: render
// whatever you want inside it (Clear / Cancel / Close — or arbitrary
// children). Visibility is the consumer's responsibility — include
// the part to show it, omit it to hide it.
//
// Carries `data-mode` so the CSS can react to the picker's mode
// without re-reading provider context.
const { children, ...rest }: TimePickerFooterProps = $props();
const provider = TimePickerProvider.require();
const mode = $derived(provider.opts.mode.current);
</script>
<div data-time-picker-footer data-mode={mode} {...rest}>
{@render children?.()}
</div>

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

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

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

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

@ -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;
}
}

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

@ -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<Size, 'sm' | 'md' | 'lg'>
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<TimePickerSize>
/** 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<TimePickerSize>
variant?: TimePickerVariant
color?: TimePickerColor
}
export type TimePickerContentProps = Omit<PopoverContentProps, 'size'> & {
size?: ResponsiveProp<TimePickerSize>
}
export type TimePickerClockProps = {
id?: string
size?: ResponsiveProp<TimePickerSize>
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<string, never>
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 }

@ -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';

@ -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;

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

@ -1,9 +1,7 @@
<script lang="ts">
import { watch } from 'runed';
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { Soma } from '../../../core/soma.svelte';
import { Time, type TimeValue } from '$libs/days';
@ -24,6 +22,7 @@
open = $bindable(false),
onOpenChange = () => {},
onOpenChangeComplete = () => {},
mode = 'inline',
validate,
onInvalid,
minValue,
@ -41,7 +40,10 @@
minuteStep = 1,
secondStep = 1,
errorMessageId,
children
ref = $bindable(null),
children,
child,
...restProps
}: TimePickerProviderProps = $props();
function defaultPlaceholder(): TimeValue {
@ -89,11 +91,16 @@
() => (dir ?? soma?.prefs.getDir() ?? 'ltr') as 'ltr' | 'rtl'
);
TimePickerProvider.create({
const state = TimePickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: sharedValue,
placeholder: sharedPlaceholder,
open: sharedOpen,
mode: readableActive(() => mode),
validate: readableActive(() => validate),
onInvalid: readableActive(() => onInvalid),
minValue: readableActive(() => minValue),
@ -118,7 +125,7 @@
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => false)
modal: readableActive(() => mode === 'modal')
});
TimeFieldProvider.create({
@ -140,6 +147,14 @@
dir: resolvedDir,
errorMessageId: readableActive(() => errorMessageId)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{@render children?.()}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -1,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';

@ -68,6 +68,7 @@ function createTimePickerOpts() {
readonly: state(false),
readonlySegments: state<EditableTimeSegmentPart[]>([]),
required: state(false),
mode: state<'inline' | 'modal'>('inline'),
granularity: state<TimeGranularity | undefined>(undefined),
hideTimeZone: state(false),
hourCycle: state<HourCycle | undefined>(undefined),

@ -1,5 +1,8 @@
import { untrack } from 'svelte';
import { watch } from 'runed';
import { context, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, boolToStr, getDataOpenClosed } from '$adom';
import { type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn, Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
@ -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 };

@ -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<TimeValue>;
// ── Open state ──
/** Whether the popover is open. Bindable. @default false */
open?: boolean;
onOpenChange?: OnChangeFn<boolean>;
onOpenChangeComplete?: OnChangeFn<boolean>;
/**
* 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<TimeValue>;
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<TimePickerBaseProps> &
Without<PrimitiveDivAttributes, TimePickerBaseProps>;
/** Props for `TimePicker.Trigger`. */
export type TimePickerTriggerProps = WithChild<{
id?: string;
@ -65,6 +72,9 @@ export type TimePickerTriggerProps = WithChild<{
}> &
Without<PrimitiveButtonAttributes, { 'aria-label'?: string }>;
// Suppress unused import warning by re-exporting Snippet
export type { Snippet };
// ── Re-exports ──────────────────────────────────────────────────────────────
export type {

@ -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' },

@ -0,0 +1,666 @@
<script lang="ts">
import {
TimePicker,
type TimePickerColor,
type TimePickerSize,
type TimePickerVariant,
type InputSnippetProps
} from '$uix/eidos/components/time-picker';
import { compileMorfo } from '$uix/morfo';
import { timePickerMorfo } from '@/uix/morfo/components/time-picker';
import { timeFieldMorfo } from '@/uix/morfo/components/time-field';
import { popoverMorfo } from '@/uix/morfo/components/popover';
import { getActiveUix } from '$active-uix';
import {
Time,
type TimeValue,
type TimeGranularity,
type HourCycle,
type EditableTimeSegmentPart
} from '$libs/days';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const startTime: TimeValue = new Time(9, 30, 0);
const endTime: TimeValue = new Time(17, 45, 0);
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<TimeValue | undefined>(startTime);
let placeholder = $state<TimeValue>(startTime);
let open = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let required = $state(true);
let disabled = $state(false);
let readonly = $state(false);
let lockSeconds = $state(false);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let bounds = $state(false);
let locale = $state('en-US');
let size = $state<TimePickerSize>('md');
let variant = $state<TimePickerVariant>('surface');
let color = $state<TimePickerColor>('primary');
// Footer composition toggles
let showFooter = $state(true);
let showClear = $state(true);
let showCancel = $state(true);
let showClose = $state(true);
const compiled = compileMorfo(timePickerMorfo);
const timeFieldCompiled = compileMorfo(timeFieldMorfo);
const popoverCompiled = compileMorfo(popoverMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const fieldEvents = $derived([...timeFieldCompiled.actions.byName.values()]);
const popoverEvents = $derived([...popoverCompiled.actions.byName.values()]);
const eventRows = $derived([
...events.map((action) => ({ owner: 'TimePicker', action })),
...fieldEvents.map((action) => ({ owner: 'TimeField', action })),
...popoverEvents.map((action) => ({ owner: 'Popover', action }))
]);
const readonlySegments = $derived<EditableTimeSegmentPart[]>(lockSeconds ? ['second'] : []);
const valueLabel = $derived(value?.toString() ?? 'empty');
const minValue: TimeValue = new Time(8, 0, 0);
const maxValue: TimeValue = new Time(20, 0, 0);
function sameValue(a: TimeValue | undefined, b: TimeValue): boolean {
return a?.toString() === b.toString();
}
function setDemoValue(kind: 'start' | 'end' | 'empty'): void {
if (kind === 'empty') {
value = undefined;
return;
}
value = kind === 'start' ? startTime : endTime;
}
function fmtTime(at: number): string {
return new Intl.DateTimeFormat(undefined, {
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 2
}).format(at);
}
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as TimePicker from '$soma/components/time-picker';",
" import { Time, type TimeValue } from '$libs/days';",
'',
' let value = $state<TimeValue | undefined>(new Time(9, 30, 0));',
' let open = $state(false);',
'</' + 'script>',
'',
'<TimePicker.Provider',
' bind:value bind:open',
mode !== 'inline' && ` mode="${mode}"`,
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
'>',
' <TimePicker.Input>',
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimePicker.Segment {part}>{value}</TimePicker.Segment>',
' {/each}',
' <TimePicker.Trigger>Open</TimePicker.Trigger>',
' {/snippet}',
' </TimePicker.Input>',
' <TimePicker.Content>',
' <TimePicker.HourSlider />',
' <TimePicker.MinuteSlider />',
' </TimePicker.Content>',
'</TimePicker.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { TimePicker } from '$uix/eidos/components/time-picker';",
" import { Time, type TimeValue } from '$libs/days';",
'',
' let value = $state<TimeValue | undefined>(new Time(9, 30, 0));',
' let open = $state(false);',
'</' + 'script>',
'',
'<TimePicker',
' bind:value bind:open',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
mode !== 'inline' && ` mode="${mode}"`,
granularity !== 'minute' && ` granularity="${granularity}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
required && ' required',
disabled && ' disabled',
readonly && ' readonly',
lockSeconds && " readonlySegments={['second']}",
bounds && ' minValue={new Time(8, 0, 0)} maxValue={new Time(20, 0, 0)}',
'>',
' <TimePicker.Input>',
' {#snippet children({ segments })}',
' {#each segments as { part, value }, i (`${part}-${i}`)}',
' <TimePicker.Segment {part}>{value}</TimePicker.Segment>',
' {/each}',
' <TimePicker.Trigger />',
' {/snippet}',
' </TimePicker.Input>',
' <TimePicker.Portal>',
' <TimePicker.Content>',
' <TimePicker.Clock>',
' <TimePicker.HourSlider />',
' <TimePicker.MinuteSlider />',
granularity === 'second' && ' <TimePicker.SecondSlider />',
hourCycle === 12 && ' <TimePicker.DayPeriodToggle />',
showFooter && (showClear || showCancel || showClose) && ' <TimePicker.Footer>',
showFooter && showClear && ' <TimePicker.Clear />',
showFooter && showCancel && ' <TimePicker.Cancel />',
showFooter && showClose && ' <TimePicker.Close />',
showFooter && (showClear || showCancel || showClose) && ' </TimePicker.Footer>',
' </TimePicker.Clock>',
' </TimePicker.Content>',
' </TimePicker.Portal>',
'</TimePicker>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · TimePicker</div>
<h1 data-uix-page-title>TimePicker</h1>
<p data-uix-page-lede>
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.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill
><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span
>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{eventRows.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>3</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: min(28rem, 100%);">
<TimePicker
bind:value
bind:placeholder
bind:open
{mode}
{required}
{disabled}
{readonly}
{readonlySegments}
{granularity}
{hourCycle}
{locale}
{size}
{variant}
{color}
minValue={bounds ? minValue : undefined}
maxValue={bounds ? maxValue : undefined}
>
<TimePicker.Label>Start time</TimePicker.Label>
<TimePicker.Input name="start-time" aria-label="Start time">
{#snippet children({ segments }: InputSnippetProps)}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimePicker.Segment {part}>{value}</TimePicker.Segment>
{/each}
<TimePicker.Trigger />
{/snippet}
</TimePicker.Input>
<TimePicker.Portal>
<TimePicker.Content>
<TimePicker.Clock>
<TimePicker.HourSlider />
<TimePicker.MinuteSlider />
{#if granularity === 'second'}
<TimePicker.SecondSlider />
{/if}
{#if hourCycle === 12}
<TimePicker.DayPeriodToggle />
{/if}
{#if showFooter && (showClear || showCancel || showClose)}
<TimePicker.Footer>
{#if showClear}<TimePicker.Clear />{/if}
{#if showCancel}<TimePicker.Cancel />{/if}
{#if showClose}<TimePicker.Close />{/if}
</TimePicker.Footer>
{/if}
</TimePicker.Clock>
</TimePicker.Content>
</TimePicker.Portal>
</TimePicker>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>edit, open the clock or play an event to see sema stamps</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
{valueLabel}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{eventRows.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns value, segment editing,
validation and the open state. <span data-uix-layer-badge="eidos">eidos</span> owns
visual treatment.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value + behaviour
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value</span>
<span data-uix-chips role="group">
<button
data-uix-chip
data-active={sameValue(value, startTime)}
onclick={() => setDemoValue('start')}>start</button
>
<button
data-uix-chip
data-active={sameValue(value, endTime)}
onclick={() => setDemoValue('end')}>end</button
>
<button
data-uix-chip
data-active={value === undefined}
onclick={() => setDemoValue('empty')}>empty</button
>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
{#each ['inline', 'modal'] as v}
<button
data-uix-chip
data-active={mode === v}
onclick={() => (mode = v as 'inline' | 'modal')}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>granularity</span>
<span data-uix-chips role="radiogroup">
{#each ['hour', 'minute', 'second'] as v}
<button
data-uix-chip
data-active={granularity === v}
onclick={() => (granularity = v as TimeGranularity)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>hour cycle</span>
<span data-uix-chips role="radiogroup">
{#each [12, 24] as v}
<button
data-uix-chip
data-active={hourCycle === v}
onclick={() => (hourCycle = v as HourCycle)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="fr-FR">fr-FR</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>states</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>required</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>disabled</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>readonly</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>constraints</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={bounds} />
<span data-uix-switch-label>min 08:00 · max 20:00</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lockSeconds} />
<span data-uix-switch-label>seconds readonly</span>
</span>
</label>
</div>
<div data-uix-subsection-head>Footer composition</div>
<p data-uix-section-desc>
Each switch decides whether the part is <em>included</em> in the markup. The parts
never read these flags — omitting the part hides it, including it always renders.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>visibility</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showFooter} />
<span data-uix-switch-label>render footer</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClear} disabled={!showFooter} />
<span data-uix-switch-label>clear</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showCancel} disabled={!showFooter} />
<span data-uix-switch-label>cancel</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showClose} disabled={!showFooter} />
<span data-uix-switch-label>close</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as v}
<button
data-uix-chip
data-active={size === v}
onclick={() => (size = v as TimePickerSize)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v}
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v as TimePickerVariant)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as v}
<button
data-uix-chip
data-active={color === v}
onclick={() => (color = v as TimePickerColor)}>{v}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · provider + sliders</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root + attached parts</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API + reference comparison</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Feature</th><th>React Aria</th><th>Ark</th><th>Chakra</th><th>UIX</th><th>Decision</th></tr>
</thead>
<tbody>
<tr><td class="name">Segmented input</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Soma owns editable segments.</td></tr>
<tr><td class="name">Popover clock</td><td>column wheel</td><td>columns</td><td>columns</td><td>sliders + AM/PM</td><td>Sliders cover the case; wheel-scroll deferred.</td></tr>
<tr><td class="name">Granularity hour/min/sec</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>Drives which slider renders.</td></tr>
<tr><td class="name">12 / 24 hour cycle</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td><td>12h adds dayPeriod toggle.</td></tr>
<tr><td class="name">mode='inline' | 'modal'</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Same contract as date-picker.</td></tr>
<tr><td class="name">Footer = composition</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>No *Button root props — pure composition.</td></tr>
<tr><td class="name">commit/cancel/clear helpers</td><td>manual</td><td>manual</td><td>manual</td><td>yes</td><td>Wired by Footer parts.</td></tr>
<tr><td class="name">Readonly segments</td><td>no</td><td>no</td><td>yes</td><td>yes</td><td>Per-segment lock without disabling input.</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">value / placeholder</td><td>soma</td><td class="default">—</td><td>Bindable TimeValue.</td></tr>
<tr><td class="name">open</td><td>soma</td><td class="default">false</td><td>Bindable popover state.</td></tr>
<tr><td class="name">mode</td><td>soma</td><td class="default">inline</td><td><code>inline</code> closes on outside-click; <code>modal</code> requires footer action.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default">—</td><td>Validation bounds.</td></tr>
<tr><td class="name">granularity</td><td>soma</td><td class="default">minute</td><td>Hour / minute / second.</td></tr>
<tr><td class="name">hourCycle</td><td>soma</td><td class="default">locale</td><td>12h adds dayPeriod segment + toggle in clock.</td></tr>
<tr><td class="name">readonlySegments</td><td>soma</td><td class="default">[]</td><td>Lock individual segments / sliders.</td></tr>
<tr><td class="name">size / variant / color</td><td>eidos</td><td class="default">md / surface / primary</td><td>Visual-only data attrs.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
</h2>
<p data-uix-section-desc>
TimePicker declares open / dismiss / commit / clear events on its own surface. The
underlying TimeField and Popover contribute their own commit/emerge events too.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Owner</th><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each eventRows as row}
<tr>
<td class="default">{row.owner}</td>
<td class="name">{row.action.name}</td>
<td class="type">{row.action.semantic.family}</td>
<td>{row.action.semantic.verb ?? '—'}</td>
<td>{row.action.semantic.sequence ?? 'pre'}</td>
<td>{row.action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const selector =
row.owner === 'TimePicker'
? '[data-time-picker]'
: row.owner === 'TimeField'
? '[data-time-field]'
: '[data-popover-content]';
const target = stageRef?.querySelector(
selector
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: row.action.name,
family: row.action.semantic.family,
target
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors live in <code>src/uix/eidos/components/time-picker/time-picker.css</code> 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).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-time-picker]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Root visual scope + size/variant/color tokens.</td></tr>
<tr><td class="name"><code>[data-time-picker-trigger]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Open-clock button. Has embedded variant inside the input.</td></tr>
<tr><td class="name"><code>[data-time-picker-clock][data-state]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Popover dialog surface. Animates from open/closed state.</td></tr>
<tr><td class="name"><code>[data-time-picker-footer][data-mode]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Footer action row. Reflects mode for CSS targeting.</td></tr>
<tr><td class="name"><code>[data-time-picker-clear], [data-time-picker-cancel], [data-time-picker-close]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Footer actions. Close has solid emphasis (Save semantics).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Dialog</td><td>Clock exposes <code>role="dialog"</code> with an accessible name.</td></tr>
<tr><td class="name">Trigger</td><td><code>aria-haspopup="dialog"</code> + <code>aria-expanded</code> + <code>aria-controls</code>.</td></tr>
<tr><td class="name">Segments</td><td>Editable segments expose <code>role="spinbutton"</code> with min/max/value.</td></tr>
<tr><td class="name">Sliders</td><td>Native slider semantics — keyboard arrow/page/home/end.</td></tr>
<tr><td class="name">Modal</td><td><code>mode='modal'</code> blocks outside-click + Escape; consumer must wire Close/Cancel.</td></tr>
<tr><td class="name">Form</td><td>Hidden input submits the ISO time string when <code>name</code> is set on Input.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.