+{/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.
+
+ 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.
+
inline closes on outside-click; modal requires footer action.
+
minValue / maxValue
soma
—
Validation bounds.
+
granularity
soma
minute
Hour / minute / second.
+
hourCycle
soma
locale
12h adds dayPeriod segment + toggle in clock.
+
readonlySegments
soma
[]
Lock individual segments / sliders.
+
size / variant / color
eidos
md / surface / primary
Visual-only data attrs.
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
Morfo contract
+
+
+
Part
Marker
Element
Role
Optional
+
+ {#each partsList as part}
+
+
{part.kebab}
+
[{part.marker}]
+
<{part.defaultElement}>
+
{part.role ?? '—'}
+
{part.optional ? 'yes' : 'no'}
+
+ {/each}
+
+
+
+
+ {/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.
+
+
+
+
Owner
Name
Family
Verb
Sequence
Target
Play
+
+ {#each eventRows as row}
+
+
{row.owner}
+
{row.action.name}
+
{row.action.semantic.family}
+
{row.action.semantic.verb ?? '—'}
+
{row.action.semantic.sequence ?? 'pre'}
+
{row.action.target}
+
+
+
+
+ {/each}
+
+
+
+
+ {/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).
+
+
+
+
Selector
Owner
Purpose
+
+
[data-time-picker]
morfo
Root visual scope + size/variant/color tokens.
+
[data-time-picker-trigger]
morfo
Open-clock button. Has embedded variant inside the input.
+
[data-time-picker-clock][data-state]
morfo
Popover dialog surface. Animates from open/closed state.
+
[data-time-picker-footer][data-mode]
morfo
Footer action row. Reflects mode for CSS targeting.