Builds the Eidos time-range-picker layer end-to-end and brings color-picker's morfo into parity with the other pickers. time-range-picker - soma: `mode` prop, `pickerShellContext` registration with commit/cancel/clear and `valueOnOpen` snapshot for revert; Provider now renders a div with data-state/disabled/readonly/required + aria mirrors. - soma: new `Clock` part (mirrors TimePicker.Clock with role="dialog" and data-state). - morfo: scope ['soma','sema'] + apg dialog-modal; promoted Provider to public div with states/data-state; added Clock part; declared the 6 canonical picker events (open / close-commit / close-cancel / close-dismiss / close-dismiss-outside / commit-clear); slider parts declare data-endpoint + data-disabled. - eidos: full namespace (Trigger / Content / Clock / HourSlider / MinuteSlider / SecondSlider / DayPeriodToggle + re-exported PickerShell Footer / Clear / Cancel / Close). Sliders compose generic SliderProvider (N-8) and ship with optional rulers above hour / below minute+second. - eidos css: 2-column endpoint grid in the clock; per-endpoint range tint; AM/PM toggle styling; reuses time-field tokens. - demo: interactive testbed at /uix/components/time-range-picker mirroring the time-picker demo — every soma + eidos prop has a live control, closeOnRangeComplete + start/endReadonlySegments toggles, footer composition switches, sema play buttons, soma+eidos snippets. - nav: added entry between Time picker and Color field. color-picker (audit fixes) - morfo: Provider declares states ['open','closed'] + data-state attr; added the same 6 canonical picker events targeting the `content` part (handle-pick / handle-drag / commit-set preserved). - soma: runtime now feeds open state + disabled/readonly/required/invalid props to the morfo runtime so data-state actually emits. Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4524 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>active-uix
parent
cae638c993
commit
dd22cfc2bc
@ -0,0 +1,98 @@
|
||||
# Eidos TimeRangePicker
|
||||
|
||||
Composite popover combining a segmented `TimeRangeField` input pair (start + end)
|
||||
and a `Clock` view with per-endpoint sliders (Hour / Minute / Second + AM/PM
|
||||
toggle). Soma owns range state coordination (`{ start, end }` + open +
|
||||
helpers `commit / cancel / clear`); Eidos adds visual treatment, the popover
|
||||
content shell, the side-by-side endpoint layout and the canonical Footer
|
||||
parts.
|
||||
|
||||
## Baseline
|
||||
|
||||
Air no tenía un `TimeRangePicker` propio en la rama anterior. La referencia
|
||||
real es la composición Soma `time-range-picker` + `time-range-field` +
|
||||
`popover` + `slider` que ya implementa la edición segmentada de ambos
|
||||
extremos y la selección por sliders independientes (`type="start" |
|
||||
"end"`). La receta Eidos sólo envuelve esa superficie con tamaños, variante
|
||||
visual y paleta de intents. Referencias externas: React Aria
|
||||
DateRangePicker (pares), Ark UI Time Picker (single), Chakra Time Picker.
|
||||
|
||||
## Superficie
|
||||
|
||||
```svelte
|
||||
<TimeRangePicker bind:value bind:open mode="inline">
|
||||
<TimeRangePicker.Input type="start">
|
||||
{#snippet children({ segments })}
|
||||
{#each segments as { part, value }, i (`${part}-${i}`)}
|
||||
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</TimeRangePicker.Input>
|
||||
<TimeRangePicker.Input type="end">
|
||||
{#snippet children({ segments })}
|
||||
{#each segments as { part, value }, i (`${part}-${i}`)}
|
||||
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
|
||||
{/each}
|
||||
<TimeRangePicker.Trigger />
|
||||
{/snippet}
|
||||
</TimeRangePicker.Input>
|
||||
|
||||
<TimeRangePicker.Portal>
|
||||
<TimeRangePicker.Content>
|
||||
<TimeRangePicker.Clock>
|
||||
<div class="time-range-picker-endpoint">
|
||||
<span class="time-range-picker-endpoint-label">Start</span>
|
||||
<TimeRangePicker.HourSlider type="start" />
|
||||
<TimeRangePicker.MinuteSlider type="start" />
|
||||
<TimeRangePicker.DayPeriodToggle type="start" />
|
||||
</div>
|
||||
<div class="time-range-picker-endpoint">
|
||||
<span class="time-range-picker-endpoint-label">End</span>
|
||||
<TimeRangePicker.HourSlider type="end" />
|
||||
<TimeRangePicker.MinuteSlider type="end" />
|
||||
<TimeRangePicker.DayPeriodToggle type="end" />
|
||||
</div>
|
||||
|
||||
<TimeRangePicker.Footer>
|
||||
<TimeRangePicker.Clear />
|
||||
<TimeRangePicker.Cancel />
|
||||
<TimeRangePicker.Close />
|
||||
</TimeRangePicker.Footer>
|
||||
</TimeRangePicker.Clock>
|
||||
</TimeRangePicker.Content>
|
||||
</TimeRangePicker.Portal>
|
||||
</TimeRangePicker>
|
||||
```
|
||||
|
||||
Partes públicas: `Trigger`, `Content`, `Clock`, `Label`, `Input`,
|
||||
`Segment`, `HourSlider`, `MinuteSlider`, `SecondSlider`,
|
||||
`DayPeriodToggle`, `Footer`, `Clear`, `Cancel`, `Close`. Cada slider /
|
||||
toggle acepta `type: 'start' | 'end'`.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Característica | React Aria | Ark | Chakra | UIX |
|
||||
| ----------------------------- | ---------- | --- | ------ | --- |
|
||||
| Inputs segmentados (par) | sí | no | no | sí |
|
||||
| Sliders por endpoint | no | no | no | sí |
|
||||
| Modo inline / modal | no | no | no | sí |
|
||||
| Footer (composición) | no | no | no | sí |
|
||||
| commit / cancel / clear shell | manual | no | no | sí |
|
||||
| Range state-machine swap | sí | n/a | n/a | sí |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **State machine empty → pending → complete** vive en el provider Soma —
|
||||
el eidos no decide cuándo se cierra. `closeOnRangeComplete` es opcional.
|
||||
- **`mode='inline'|'modal'`** propaga al popover (`modal: mode === 'modal'`).
|
||||
- **Footer = composición** — sin `*Button` props, igual que TimePicker.
|
||||
- **N-8** — los sliders comparten `SliderProvider` (no se reimplementa
|
||||
drag / teclado).
|
||||
- **Layout 2 columnas** — Start y End ocupan columnas independientes en
|
||||
el popover. Por debajo de 40rem caen apiladas.
|
||||
|
||||
## Gaps
|
||||
|
||||
- No hay vista "preset list" (Hoy, Última hora…). Se puede componer fuera.
|
||||
- No hay swap automático visible si el usuario fija `end < start` — la
|
||||
validación queda en manos del `validate` opt.
|
||||
@ -0,0 +1,32 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type {
|
||||
TimeRangePickerColor,
|
||||
TimeRangePickerSize,
|
||||
TimeRangePickerVariant
|
||||
} from './types';
|
||||
|
||||
const TIME_RANGE_PICKER_VISUAL_CONTEXT = Symbol('uix.eidos.time-range-picker.visual');
|
||||
|
||||
export interface TimeRangePickerVisualContext {
|
||||
getSize(): TimeRangePickerSize;
|
||||
getVariant(): TimeRangePickerVariant;
|
||||
getColor(): TimeRangePickerColor;
|
||||
/**
|
||||
* Whether to render the labelled ruler scale next to each slider.
|
||||
* `true` by default; pass `ruler={false}` on `<TimeRangePicker>` to hide
|
||||
* the rulers entirely, or `ruler={false}` on an individual slider eidos
|
||||
* wrapper to opt that one out.
|
||||
*/
|
||||
getRuler(): boolean;
|
||||
}
|
||||
|
||||
export function setTimeRangePickerVisualContext(
|
||||
ctx: TimeRangePickerVisualContext
|
||||
): TimeRangePickerVisualContext {
|
||||
setContext(TIME_RANGE_PICKER_VISUAL_CONTEXT, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function getTimeRangePickerVisualContext(): TimeRangePickerVisualContext | undefined {
|
||||
return getContext<TimeRangePickerVisualContext | undefined>(TIME_RANGE_PICKER_VISUAL_CONTEXT);
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import TimeRangePickerComponent from './time-range-picker.svelte';
|
||||
import Trigger from './time-range-picker-trigger.svelte';
|
||||
import Content from './time-range-picker-content.svelte';
|
||||
import Clock from './time-range-picker-clock.svelte';
|
||||
// PickerShell parts — shared by every composite picker.
|
||||
import Footer from '../picker-shell/picker-shell-footer.svelte';
|
||||
import Clear from '../picker-shell/picker-shell-clear.svelte';
|
||||
import Cancel from '../picker-shell/picker-shell-cancel.svelte';
|
||||
import Close from '../picker-shell/picker-shell-close.svelte';
|
||||
import HourSlider from './time-range-picker-hour-slider.svelte';
|
||||
import MinuteSlider from './time-range-picker-minute-slider.svelte';
|
||||
import SecondSlider from './time-range-picker-second-slider.svelte';
|
||||
import DayPeriodToggle from './time-range-picker-day-period-toggle.svelte';
|
||||
// Field surface is re-exported direct from soma — eidos shares the
|
||||
// time-range-field styling layer via [data-time-range-field] selectors.
|
||||
import * as TimeRangePickerSoma from '$soma/components/time-range-picker';
|
||||
import Anchor from '../popover/popover-anchor.svelte';
|
||||
import Overlay from '../popover/popover-overlay.svelte';
|
||||
import Arrow from '../popover/popover-arrow.svelte';
|
||||
import { Portal } from '$soma/components/internal';
|
||||
|
||||
const Label = TimeRangePickerSoma.Label;
|
||||
const Input = TimeRangePickerSoma.Input;
|
||||
const Segment = TimeRangePickerSoma.Segment;
|
||||
|
||||
type TimeRangePickerNamespace = typeof TimeRangePickerComponent & {
|
||||
Trigger: typeof Trigger;
|
||||
Portal: typeof Portal;
|
||||
Anchor: typeof Anchor;
|
||||
Overlay: typeof Overlay;
|
||||
Content: typeof Content;
|
||||
Arrow: typeof Arrow;
|
||||
Clock: typeof Clock;
|
||||
Footer: typeof Footer;
|
||||
Clear: typeof Clear;
|
||||
Cancel: typeof Cancel;
|
||||
Close: typeof Close;
|
||||
HourSlider: typeof HourSlider;
|
||||
MinuteSlider: typeof MinuteSlider;
|
||||
SecondSlider: typeof SecondSlider;
|
||||
DayPeriodToggle: typeof DayPeriodToggle;
|
||||
Label: typeof Label;
|
||||
Input: typeof Input;
|
||||
Segment: typeof Segment;
|
||||
};
|
||||
|
||||
const TimeRangePicker = TimeRangePickerComponent as TimeRangePickerNamespace;
|
||||
TimeRangePicker.Trigger = Trigger;
|
||||
TimeRangePicker.Portal = Portal;
|
||||
TimeRangePicker.Anchor = Anchor;
|
||||
TimeRangePicker.Overlay = Overlay;
|
||||
TimeRangePicker.Content = Content;
|
||||
TimeRangePicker.Arrow = Arrow;
|
||||
TimeRangePicker.Clock = Clock;
|
||||
TimeRangePicker.Footer = Footer;
|
||||
TimeRangePicker.Clear = Clear;
|
||||
TimeRangePicker.Cancel = Cancel;
|
||||
TimeRangePicker.Close = Close;
|
||||
TimeRangePicker.HourSlider = HourSlider;
|
||||
TimeRangePicker.MinuteSlider = MinuteSlider;
|
||||
TimeRangePicker.SecondSlider = SecondSlider;
|
||||
TimeRangePicker.DayPeriodToggle = DayPeriodToggle;
|
||||
TimeRangePicker.Label = Label;
|
||||
TimeRangePicker.Input = Input;
|
||||
TimeRangePicker.Segment = Segment;
|
||||
|
||||
export { TimeRangePicker };
|
||||
|
||||
export default TimeRangePicker;
|
||||
|
||||
export type {
|
||||
TimeRangePickerProps,
|
||||
TimeRangePickerTriggerProps as TriggerProps,
|
||||
TimeRangePickerContentProps as ContentProps,
|
||||
TimeRangePickerClockProps as ClockProps,
|
||||
TimeRangePickerFooterProps as FooterProps,
|
||||
TimeRangePickerClearProps as ClearProps,
|
||||
TimeRangePickerCancelProps as CancelProps,
|
||||
TimeRangePickerCloseProps as CloseProps,
|
||||
TimeRangePickerLabelProps as LabelProps,
|
||||
TimeRangePickerInputProps as InputProps,
|
||||
TimeRangePickerSegmentProps as SegmentProps,
|
||||
TimeRangePickerHiddenInputProps as HiddenInputProps,
|
||||
TimeRangePickerAnchorProps as AnchorProps,
|
||||
TimeRangePickerOverlayProps as OverlayProps,
|
||||
TimeRangePickerArrowProps as ArrowProps,
|
||||
TimeRangePickerSliderProps as SliderProps,
|
||||
TimeRangePickerDayPeriodToggleProps as DayPeriodToggleProps,
|
||||
InputSnippetProps,
|
||||
TimeRangePickerSize,
|
||||
TimeRangePickerVariant,
|
||||
TimeRangePickerColor
|
||||
} from './types';
|
||||
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerClockProps } from './types';
|
||||
|
||||
let { size, variant, color, children, ...rest }: TimeRangePickerClockProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
|
||||
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'ghost');
|
||||
const resolvedColor = $derived(color ?? visual?.getColor() ?? 'primary');
|
||||
</script>
|
||||
|
||||
<TimeRangePicker.Clock
|
||||
{...rest}
|
||||
data-size={resolvedSize}
|
||||
data-variant={resolvedVariant}
|
||||
data-color={resolvedColor}
|
||||
>
|
||||
{@render children?.()}
|
||||
</TimeRangePicker.Clock>
|
||||
@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { composeInlineStyle } from '$uix/eidos/lib/style';
|
||||
import PopoverContent from '../popover/popover-content.svelte';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerContentProps } from './types';
|
||||
|
||||
let {
|
||||
size,
|
||||
align = 'end',
|
||||
sideOffset = 10,
|
||||
collisionPadding = 12,
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
style,
|
||||
children: bodyContent,
|
||||
...rest
|
||||
}: TimeRangePickerContentProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
|
||||
|
||||
function serializeLength(value: string | number | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? `${value}px` : undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function dimensionVar(name: string, value: string | number | undefined): string | undefined {
|
||||
const serialized = serializeLength(value);
|
||||
return serialized ? `${name}: ${serialized};` : undefined;
|
||||
}
|
||||
|
||||
function resolveLength(value: typeof width, fallback: string): string | number | undefined {
|
||||
const resolved = eidos.resolve(value);
|
||||
if (typeof resolved === 'string' || typeof resolved === 'number') return resolved;
|
||||
if (typeof value === 'string' || typeof value === 'number') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const dimensionsStyle = $derived(
|
||||
[
|
||||
dimensionVar('--_popover-content-width-override', resolveLength(width, 'max-content')),
|
||||
dimensionVar('--_popover-content-min-width-override', resolveLength(minWidth, 'max-content')),
|
||||
dimensionVar(
|
||||
'--_popover-content-max-width-override',
|
||||
resolveLength(maxWidth, 'calc(100vw - var(--space-4))')
|
||||
)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
const composedStyle = $derived(composeInlineStyle(style, dimensionsStyle));
|
||||
</script>
|
||||
|
||||
<PopoverContent
|
||||
{...rest}
|
||||
{align}
|
||||
{sideOffset}
|
||||
{collisionPadding}
|
||||
size={resolvedSize}
|
||||
style={composedStyle}
|
||||
>
|
||||
{#snippet children(snippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
{/snippet}
|
||||
</PopoverContent>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import type { TimeRangePickerDayPeriodToggleProps } from './types';
|
||||
|
||||
let { children, ...rest }: TimeRangePickerDayPeriodToggleProps = $props();
|
||||
</script>
|
||||
|
||||
<TimeRangePicker.DayPeriodToggle {...rest}>
|
||||
{@render children?.()}
|
||||
</TimeRangePicker.DayPeriodToggle>
|
||||
@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import { TimeRangePickerProvider } from '$soma/components/time-range-picker';
|
||||
import * as Slider from '$soma/components/slider';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerSliderProps } from './types';
|
||||
|
||||
type Props = TimeRangePickerSliderProps & {
|
||||
/** Show the labelled ruler above the track. Falls back to the
|
||||
* picker-level `ruler` flag (default `true`). */
|
||||
ruler?: boolean;
|
||||
};
|
||||
|
||||
let { children, ruler, type, ...rest }: Props = $props();
|
||||
|
||||
const provider = TimeRangePickerProvider.require();
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const cycle = $derived(provider.resolvedHourCycle);
|
||||
|
||||
// Endpoint-inclusive scale. 24h shows full available range.
|
||||
const tickValues = $derived.by(() => {
|
||||
if (cycle === 12) return [1, 3, 6, 9, 12];
|
||||
return [0, 3, 6, 9, 12, 15, 18, 21, 23];
|
||||
});
|
||||
|
||||
const min = $derived(provider.hourMin);
|
||||
const max = $derived(provider.hourMax);
|
||||
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
|
||||
|
||||
function percent(v: number): string {
|
||||
const t = (v - min) / (max - min || 1);
|
||||
return `${Math.max(0, Math.min(1, t)) * 100}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-time-range-picker-hour-slider
|
||||
data-endpoint={type}
|
||||
class="time-range-picker-slider time-range-picker-hour-slider"
|
||||
>
|
||||
{#if showRuler}
|
||||
<div class="time-range-picker-scale" aria-hidden="true">
|
||||
{#each tickValues as v}
|
||||
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
|
||||
<span class="time-range-picker-scale-mark"></span>
|
||||
<span class="time-range-picker-scale-label"
|
||||
>{cycle === 12 ? v : String(v).padStart(2, '0')}</span
|
||||
>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<TimeRangePicker.HourSlider {type} {...rest}>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<Slider.Range />
|
||||
<Slider.Thumb />
|
||||
{/if}
|
||||
</TimeRangePicker.HourSlider>
|
||||
</div>
|
||||
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import * as Slider from '$soma/components/slider';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerSliderProps } from './types';
|
||||
|
||||
type Props = TimeRangePickerSliderProps & {
|
||||
ruler?: boolean;
|
||||
};
|
||||
|
||||
let { children, ruler, type, ...rest }: Props = $props();
|
||||
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
|
||||
|
||||
const tickValues = [0, 15, 30, 45, 59];
|
||||
const min = 0;
|
||||
const max = 59;
|
||||
|
||||
function percent(v: number): string {
|
||||
const t = (v - min) / (max - min || 1);
|
||||
return `${Math.max(0, Math.min(1, t)) * 100}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-time-range-picker-minute-slider
|
||||
data-endpoint={type}
|
||||
class="time-range-picker-slider time-range-picker-minute-slider"
|
||||
>
|
||||
{#if showRuler}
|
||||
<div class="time-range-picker-scale time-range-picker-scale-below" aria-hidden="true">
|
||||
{#each tickValues as v}
|
||||
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
|
||||
<span class="time-range-picker-scale-mark"></span>
|
||||
<span class="time-range-picker-scale-label">{String(v).padStart(2, '0')}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<TimeRangePicker.MinuteSlider {type} {...rest}>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<Slider.Range />
|
||||
<Slider.Thumb />
|
||||
{/if}
|
||||
</TimeRangePicker.MinuteSlider>
|
||||
</div>
|
||||
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import * as Slider from '$soma/components/slider';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerSliderProps } from './types';
|
||||
|
||||
type Props = TimeRangePickerSliderProps & {
|
||||
ruler?: boolean;
|
||||
};
|
||||
|
||||
let { children, ruler, type, ...rest }: Props = $props();
|
||||
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
|
||||
|
||||
const tickValues = [0, 15, 30, 45, 59];
|
||||
const min = 0;
|
||||
const max = 59;
|
||||
|
||||
function percent(v: number): string {
|
||||
const t = (v - min) / (max - min || 1);
|
||||
return `${Math.max(0, Math.min(1, t)) * 100}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-time-range-picker-second-slider
|
||||
data-endpoint={type}
|
||||
class="time-range-picker-slider time-range-picker-second-slider"
|
||||
>
|
||||
{#if showRuler}
|
||||
<div class="time-range-picker-scale time-range-picker-scale-below" aria-hidden="true">
|
||||
{#each tickValues as v}
|
||||
<span class="time-range-picker-scale-tick" style:left={percent(v)}>
|
||||
<span class="time-range-picker-scale-mark"></span>
|
||||
<span class="time-range-picker-scale-label">{String(v).padStart(2, '0')}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<TimeRangePicker.SecondSlider {type} {...rest}>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<Slider.Range />
|
||||
<Slider.Thumb />
|
||||
{/if}
|
||||
</TimeRangePicker.SecondSlider>
|
||||
</div>
|
||||
@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import ClockIcon from '../icon/lib/clock.svelte';
|
||||
import { getTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerTriggerProps } from './types';
|
||||
|
||||
let { size, variant, color, children, ...rest }: TimeRangePickerTriggerProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const visual = getTimeRangePickerVisualContext();
|
||||
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
|
||||
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'surface');
|
||||
const resolvedColor = $derived(color ?? visual?.getColor() ?? 'primary');
|
||||
</script>
|
||||
|
||||
<TimeRangePicker.Trigger
|
||||
{...rest}
|
||||
data-size={resolvedSize}
|
||||
data-variant={resolvedVariant}
|
||||
data-color={resolvedColor}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<ClockIcon size="1em" decorative />
|
||||
{/if}
|
||||
</TimeRangePicker.Trigger>
|
||||
@ -0,0 +1,320 @@
|
||||
/* TimeRangePicker — visual layer.
|
||||
*
|
||||
* Mirrors the TimePicker recipe. Reuses time-field tokens for the field
|
||||
* portion (Input + Segments) and the popover surface tokens for the clock
|
||||
* body. Adds an endpoint header above each slider pair so users can tell
|
||||
* which slider drives the start vs. end of the range.
|
||||
*/
|
||||
|
||||
[data-time-range-picker] {
|
||||
--_time-field-height: var(--time-field-height-md);
|
||||
--_time-field-px: var(--time-field-px-md);
|
||||
--_time-field-gap: var(--time-field-gap-md);
|
||||
--_time-field-segment-px: var(--time-field-segment-px-md);
|
||||
--_time-field-font-size: var(--time-field-font-size-md);
|
||||
--_time-field-accent-border: var(--color-primary-border);
|
||||
--_time-field-accent-track: var(--color-primary-track);
|
||||
--_time-field-accent-text: var(--color-primary-text);
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: var(--time-field-stack-gap);
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
color: var(--time-field-color);
|
||||
font-family: var(--time-field-font-family);
|
||||
font-size: var(--_time-field-font-size);
|
||||
line-height: var(--time-field-line-height);
|
||||
}
|
||||
|
||||
[data-time-range-picker][data-size='sm'] {
|
||||
--_time-field-height: var(--time-field-height-sm);
|
||||
--_time-field-px: var(--time-field-px-sm);
|
||||
--_time-field-gap: var(--time-field-gap-sm);
|
||||
--_time-field-segment-px: var(--time-field-segment-px-sm);
|
||||
--_time-field-font-size: var(--time-field-font-size-sm);
|
||||
}
|
||||
|
||||
[data-time-range-picker][data-size='lg'] {
|
||||
--_time-field-height: var(--time-field-height-lg);
|
||||
--_time-field-px: var(--time-field-px-lg);
|
||||
--_time-field-gap: var(--time-field-gap-lg);
|
||||
--_time-field-segment-px: var(--time-field-segment-px-lg);
|
||||
--_time-field-font-size: var(--time-field-font-size-lg);
|
||||
}
|
||||
|
||||
[data-time-range-picker][data-color='secondary'] {
|
||||
--_time-field-accent-border: var(--color-secondary-border);
|
||||
--_time-field-accent-track: var(--color-secondary-track);
|
||||
--_time-field-accent-text: var(--color-secondary-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='neutral'] {
|
||||
--_time-field-accent-border: var(--color-neutral-border);
|
||||
--_time-field-accent-track: var(--color-neutral-track);
|
||||
--_time-field-accent-text: var(--color-neutral-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='affirm'] {
|
||||
--_time-field-accent-border: var(--color-affirm-border);
|
||||
--_time-field-accent-track: var(--color-affirm-track);
|
||||
--_time-field-accent-text: var(--color-affirm-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='fulfill'] {
|
||||
--_time-field-accent-border: var(--color-fulfill-border);
|
||||
--_time-field-accent-track: var(--color-fulfill-track);
|
||||
--_time-field-accent-text: var(--color-fulfill-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='risk'] {
|
||||
--_time-field-accent-border: var(--color-risk-border);
|
||||
--_time-field-accent-track: var(--color-risk-track);
|
||||
--_time-field-accent-text: var(--color-risk-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='threat'] {
|
||||
--_time-field-accent-border: var(--color-threat-border);
|
||||
--_time-field-accent-track: var(--color-threat-track);
|
||||
--_time-field-accent-text: var(--color-threat-text);
|
||||
}
|
||||
[data-time-range-picker][data-color='loss'] {
|
||||
--_time-field-accent-border: var(--color-loss-border);
|
||||
--_time-field-accent-track: var(--color-loss-track);
|
||||
--_time-field-accent-text: var(--color-loss-text);
|
||||
}
|
||||
|
||||
/* ── Trigger ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-time-range-picker-trigger] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
inline-size: var(--_time-field-height);
|
||||
block-size: var(--_time-field-height);
|
||||
padding: 0;
|
||||
border: var(--time-field-border-width) solid var(--time-field-border);
|
||||
border-radius: var(--time-field-radius);
|
||||
background: var(--time-field-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--time-field-transition-duration) var(--time-field-transition-ease),
|
||||
border-color var(--time-field-transition-duration) var(--time-field-transition-ease),
|
||||
box-shadow var(--time-field-transition-duration) var(--time-field-transition-ease),
|
||||
color var(--time-field-transition-duration) var(--time-field-transition-ease);
|
||||
}
|
||||
|
||||
[data-time-range-picker-trigger]:hover {
|
||||
border-color: var(--_time-field-accent-border);
|
||||
background: var(--_time-field-accent-track);
|
||||
color: var(--_time-field-accent-text);
|
||||
}
|
||||
|
||||
[data-time-range-picker-trigger]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--_time-field-accent-border);
|
||||
box-shadow: var(--time-field-focus-shadow);
|
||||
}
|
||||
|
||||
[data-time-range-picker-trigger][data-disabled],
|
||||
[data-time-range-picker-trigger]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--time-field-disabled-opacity);
|
||||
}
|
||||
|
||||
/* Embedded trigger inside the input — flush affordance like time-picker. */
|
||||
[data-time-range-field-input] [data-time-range-picker-trigger] {
|
||||
flex: 0 0 auto;
|
||||
inline-size: var(--time-field-segment-height);
|
||||
block-size: var(--time-field-segment-height);
|
||||
margin-inline-start: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--time-field-segment-radius);
|
||||
background: transparent;
|
||||
color: var(--time-field-segment-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-time-range-field-input] [data-time-range-picker-trigger]:hover {
|
||||
border: none;
|
||||
background: var(--_time-field-accent-track);
|
||||
color: var(--_time-field-accent-text);
|
||||
}
|
||||
|
||||
[data-time-range-field-input] [data-time-range-picker-trigger]:focus-visible {
|
||||
outline: none;
|
||||
border: none;
|
||||
background: var(--_time-field-accent-track);
|
||||
color: var(--_time-field-accent-text);
|
||||
box-shadow: var(--time-field-segment-focus-shadow);
|
||||
}
|
||||
|
||||
/* ── Popover content / Clock surface ─────────────────────────────────────── */
|
||||
|
||||
[data-popover-content]:has([data-time-range-picker-clock]) {
|
||||
--_popover-content-width-override: max-content;
|
||||
--_popover-content-min-width-override: max-content;
|
||||
--_popover-content-max-width-override: calc(100vw - var(--space-4));
|
||||
}
|
||||
|
||||
[data-time-range-picker-clock] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
min-inline-size: 32rem;
|
||||
color: var(--color-content-primary);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--leading-ui);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
[data-time-range-picker-clock] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-inline-size: 18rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-time-range-picker-clock] [data-slider] {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/* Footer spans both endpoint columns. */
|
||||
[data-time-range-picker-clock] [data-picker-footer] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Endpoint column — wraps a stack of sliders + label. The eidos demo
|
||||
provides the column structure; the recipe only styles the column. */
|
||||
.time-range-picker-endpoint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.time-range-picker-endpoint-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-content-muted);
|
||||
}
|
||||
|
||||
/* ── Labelled scale (ruler) ─────────────────────────────────────────────── */
|
||||
|
||||
.time-range-picker-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.time-range-picker-scale {
|
||||
position: relative;
|
||||
block-size: 1.25rem;
|
||||
inline-size: 100%;
|
||||
padding-inline: calc(var(--_slider-thumb-size, 1rem) / 2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.time-range-picker-scale-tick {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
color: var(--color-content-muted);
|
||||
font-family: var(--font-mono, var(--font-ui));
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.time-range-picker-scale-mark {
|
||||
display: block;
|
||||
inline-size: 1px;
|
||||
block-size: 6px;
|
||||
background: var(--color-border-default);
|
||||
}
|
||||
|
||||
.time-range-picker-scale-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.time-range-picker-scale-below .time-range-picker-scale-tick {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* ── Slider thumb / range emphasis inside the clock ──────────────────────── */
|
||||
|
||||
[data-time-range-picker-clock] [data-slider-thumb] {
|
||||
border-color: var(--_time-field-accent-border);
|
||||
box-shadow:
|
||||
var(--slider-thumb-shadow),
|
||||
0 0 0 2px var(--color-surface-default);
|
||||
}
|
||||
|
||||
[data-time-range-picker-clock] [data-slider-thumb]:focus-visible {
|
||||
box-shadow:
|
||||
var(--slider-thumb-focus-shadow),
|
||||
0 0 0 2px var(--color-surface-default);
|
||||
}
|
||||
|
||||
[data-time-range-picker-clock] [data-slider-range] {
|
||||
background: var(--_time-field-accent-border);
|
||||
}
|
||||
|
||||
/* Visually distinguish end-endpoint sliders with a slightly different track tint. */
|
||||
[data-time-range-picker-clock] [data-endpoint='end'] [data-slider-range] {
|
||||
background: color-mix(in oklch, var(--_time-field-accent-border) 80%, var(--color-content-muted));
|
||||
}
|
||||
|
||||
/* ── Day period toggle ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-time-range-picker-day-period-toggle] {
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-default);
|
||||
}
|
||||
|
||||
[data-time-range-picker-day-period-item] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: 2.25rem;
|
||||
padding-inline: var(--space-2);
|
||||
padding-block: var(--space-1);
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-content-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-time-range-picker-day-period-item][data-checked] {
|
||||
background: var(--_time-field-accent-track);
|
||||
color: var(--_time-field-accent-text);
|
||||
}
|
||||
|
||||
[data-time-range-picker-day-period-item] input[type='radio'] {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-time-range-picker-trigger] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as TimeRangePicker from '$soma/components/time-range-picker';
|
||||
import { setTimeRangePickerVisualContext } from './context.svelte';
|
||||
import type { TimeRangePickerProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
variant = 'surface',
|
||||
color = 'primary',
|
||||
ruler = true,
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
open = $bindable(false),
|
||||
children,
|
||||
...rest
|
||||
}: TimeRangePickerProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
|
||||
setTimeRangePickerVisualContext({
|
||||
getSize: () => resolvedSize,
|
||||
getVariant: () => variant,
|
||||
getColor: () => color,
|
||||
getRuler: () => ruler
|
||||
});
|
||||
</script>
|
||||
|
||||
<TimeRangePicker.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
bind:placeholder
|
||||
bind:open
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{@render children?.()}
|
||||
</TimeRangePicker.Provider>
|
||||
@ -0,0 +1,90 @@
|
||||
import type {
|
||||
ContentProps as SomaTimeRangePickerContentProps,
|
||||
InputProps as SomaTimeRangePickerInputProps,
|
||||
InputSnippetProps as TimeRangePickerInputSnippetProps,
|
||||
LabelProps as SomaTimeRangePickerLabelProps,
|
||||
ProviderProps,
|
||||
SegmentProps as SomaTimeRangePickerSegmentProps,
|
||||
SliderProps as SomaTimeRangePickerSliderProps,
|
||||
SliderThumbProps as SomaTimeRangePickerSliderThumbProps,
|
||||
SliderRangeProps as SomaTimeRangePickerSliderRangeProps,
|
||||
SliderTickProps as SomaTimeRangePickerSliderTickProps,
|
||||
TriggerProps as SomaTimeRangePickerTriggerProps,
|
||||
DayPeriodToggleProps as SomaTimeRangePickerDayPeriodToggleProps
|
||||
} from '$soma/components/time-range-picker';
|
||||
import type {
|
||||
PopoverAnchorProps,
|
||||
PopoverContentProps,
|
||||
PopoverOverlayProps,
|
||||
PopoverArrowProps,
|
||||
PopoverCloseProps
|
||||
} from '../popover/types';
|
||||
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export type TimeRangePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
export type TimeRangePickerVariant = ControlVariant;
|
||||
export type TimeRangePickerColor = ColorRole;
|
||||
|
||||
export type TimeRangePickerProps = ProviderProps & {
|
||||
/** Visual size for input segments, trigger and clock. @default 'md' */
|
||||
size?: ResponsiveProp<TimeRangePickerSize>;
|
||||
/** Visual treatment shared by the field and clock. @default 'surface' */
|
||||
variant?: TimeRangePickerVariant;
|
||||
/** Intent palette used for focus and selected affordances. @default 'primary' */
|
||||
color?: TimeRangePickerColor;
|
||||
/**
|
||||
* Show the labelled ruler scale next to each slider in the clock popover.
|
||||
* @default true
|
||||
*/
|
||||
ruler?: boolean;
|
||||
};
|
||||
|
||||
export type TimeRangePickerTriggerProps = SomaTimeRangePickerTriggerProps & {
|
||||
size?: ResponsiveProp<TimeRangePickerSize>;
|
||||
variant?: TimeRangePickerVariant;
|
||||
color?: TimeRangePickerColor;
|
||||
};
|
||||
|
||||
export type TimeRangePickerContentProps = Omit<PopoverContentProps, 'size'> & {
|
||||
size?: ResponsiveProp<TimeRangePickerSize>;
|
||||
};
|
||||
|
||||
export type TimeRangePickerClockProps = {
|
||||
id?: string;
|
||||
size?: ResponsiveProp<TimeRangePickerSize>;
|
||||
variant?: TimeRangePickerVariant;
|
||||
color?: TimeRangePickerColor;
|
||||
class?: string;
|
||||
style?: string;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
// PickerShell action-row props — Footer/Clear/Cancel/Close are shared.
|
||||
export type {
|
||||
FooterProps as TimeRangePickerFooterProps,
|
||||
ClearProps as TimeRangePickerClearProps,
|
||||
CancelProps as TimeRangePickerCancelProps,
|
||||
CloseProps as TimeRangePickerCloseProps
|
||||
} from '../picker-shell';
|
||||
|
||||
export type TimeRangePickerLabelProps = SomaTimeRangePickerLabelProps;
|
||||
export type TimeRangePickerInputProps = SomaTimeRangePickerInputProps;
|
||||
export type TimeRangePickerSegmentProps = SomaTimeRangePickerSegmentProps;
|
||||
export type TimeRangePickerHiddenInputProps = Record<string, never>;
|
||||
|
||||
export type TimeRangePickerAnchorProps = PopoverAnchorProps;
|
||||
export type TimeRangePickerOverlayProps = PopoverOverlayProps;
|
||||
export type TimeRangePickerArrowProps = PopoverArrowProps;
|
||||
export type TimeRangePickerPopoverCloseProps = PopoverCloseProps;
|
||||
|
||||
export type TimeRangePickerSliderProps = SomaTimeRangePickerSliderProps;
|
||||
export type TimeRangePickerSliderThumbProps = SomaTimeRangePickerSliderThumbProps;
|
||||
export type TimeRangePickerSliderRangeProps = SomaTimeRangePickerSliderRangeProps;
|
||||
export type TimeRangePickerSliderTickProps = SomaTimeRangePickerSliderTickProps;
|
||||
export type TimeRangePickerDayPeriodToggleProps = SomaTimeRangePickerDayPeriodToggleProps;
|
||||
|
||||
export type {
|
||||
TimeRangePickerInputSnippetProps,
|
||||
TimeRangePickerInputSnippetProps as InputSnippetProps
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import {
|
||||
TimeRangePickerProvider,
|
||||
timeRangePickerAttrs
|
||||
} from '../time-range-picker-provider.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface ClockProps extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
ref?: HTMLElement | null;
|
||||
children?: Snippet;
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
}
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid, 'time-range-picker-clock'),
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ClockProps = $props();
|
||||
|
||||
const provider = TimeRangePickerProvider.require();
|
||||
|
||||
const mergedProps = $derived(
|
||||
mergeProps(restProps, {
|
||||
id,
|
||||
role: 'dialog' as const,
|
||||
[timeRangePickerAttrs.clock]: '',
|
||||
'data-state': provider.opts.open.current ? 'open' : 'closed'
|
||||
} as Record<string, unknown>)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,743 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
TimeRangePicker,
|
||||
type TimeRangePickerColor,
|
||||
type TimeRangePickerSize,
|
||||
type TimeRangePickerVariant,
|
||||
type InputSnippetProps
|
||||
} from '$uix/eidos/components/time-range-picker';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { timeRangePickerMorfo } from '@/uix/morfo/components/time-range-picker';
|
||||
import { timeRangeFieldMorfo } from '@/uix/morfo/components/time-range-field';
|
||||
import { popoverMorfo } from '@/uix/morfo/components/popover';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
import {
|
||||
Time,
|
||||
type TimeValue,
|
||||
type TimeRange,
|
||||
type TimeGranularity,
|
||||
type HourCycle,
|
||||
type EditableTimeSegmentPart
|
||||
} from '$libs/days';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
||||
|
||||
const businessRange: TimeRange = {
|
||||
start: new Time(9, 0, 0),
|
||||
end: new Time(17, 0, 0)
|
||||
};
|
||||
const lunchRange: TimeRange = {
|
||||
start: new Time(13, 0, 0),
|
||||
end: new Time(14, 30, 0)
|
||||
};
|
||||
|
||||
let tab = $state<Tab>('live');
|
||||
let trace = $state<TraceEntry[]>([]);
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
let value = $state<TimeRange | undefined>(businessRange);
|
||||
let placeholder = $state<TimeValue>(new Time(9, 0, 0));
|
||||
let open = $state(false);
|
||||
let mode = $state<'inline' | 'modal'>('inline');
|
||||
let required = $state(false);
|
||||
let disabled = $state(false);
|
||||
let readonly = $state(false);
|
||||
let lockSeconds = $state(false);
|
||||
let ruler = $state(true);
|
||||
let granularity = $state<TimeGranularity>('minute');
|
||||
let hourCycle = $state<HourCycle>(24);
|
||||
let bounds = $state(false);
|
||||
let closeOnRangeComplete = $state(false);
|
||||
let locale = $state('en-US');
|
||||
let size = $state<TimeRangePickerSize>('md');
|
||||
let variant = $state<TimeRangePickerVariant>('surface');
|
||||
let color = $state<TimeRangePickerColor>('primary');
|
||||
|
||||
// Footer composition toggles
|
||||
let showFooter = $state(true);
|
||||
let showClear = $state(true);
|
||||
let showCancel = $state(true);
|
||||
let showClose = $state(true);
|
||||
|
||||
const compiled = compileMorfo(timeRangePickerMorfo);
|
||||
const fieldCompiled = compileMorfo(timeRangeFieldMorfo);
|
||||
const popoverCompiled = compileMorfo(popoverMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const events = $derived([...compiled.actions.byName.values()]);
|
||||
const fieldEvents = $derived([...fieldCompiled.actions.byName.values()]);
|
||||
const popoverEvents = $derived([...popoverCompiled.actions.byName.values()]);
|
||||
const eventRows = $derived([
|
||||
...events.map((action) => ({ owner: 'TimeRangePicker', action })),
|
||||
...fieldEvents.map((action) => ({ owner: 'TimeRangeField', action })),
|
||||
...popoverEvents.map((action) => ({ owner: 'Popover', action }))
|
||||
]);
|
||||
const readonlySegments = $derived<EditableTimeSegmentPart[]>(lockSeconds ? ['second'] : []);
|
||||
const valueLabel = $derived(
|
||||
value?.start && value?.end
|
||||
? `${value.start.toString()} → ${value.end.toString()}`
|
||||
: 'empty'
|
||||
);
|
||||
|
||||
const minValue: TimeValue = new Time(8, 0, 0);
|
||||
const maxValue: TimeValue = new Time(20, 0, 0);
|
||||
|
||||
function sameRange(a: TimeRange | undefined, b: TimeRange): boolean {
|
||||
return (
|
||||
a?.start?.toString() === b.start?.toString() && a?.end?.toString() === b.end?.toString()
|
||||
);
|
||||
}
|
||||
|
||||
function setDemoValue(kind: 'business' | 'lunch' | 'empty'): void {
|
||||
if (kind === 'empty') {
|
||||
value = undefined;
|
||||
return;
|
||||
}
|
||||
value = kind === 'business' ? businessRange : lunchRange;
|
||||
}
|
||||
|
||||
function fmtTime(at: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 2
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = stageRef;
|
||||
if (!el) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.attributeName !== 'data-event') continue;
|
||||
const target = m.target as Element;
|
||||
const ev = target.getAttribute('data-event');
|
||||
if (!ev) continue;
|
||||
trace = [
|
||||
{
|
||||
event: ev,
|
||||
family: target.getAttribute('data-event-family') ?? '—',
|
||||
intent: target.getAttribute('data-event-intent') ?? undefined,
|
||||
at: Date.now()
|
||||
},
|
||||
...trace
|
||||
].slice(0, 6);
|
||||
}
|
||||
});
|
||||
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
const somaSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import * as TimeRangePicker from '$soma/components/time-range-picker';",
|
||||
" import { Time, type TimeRange } from '$libs/days';",
|
||||
'',
|
||||
' let value = $state<TimeRange | undefined>({',
|
||||
' start: new Time(9, 0, 0),',
|
||||
' end: new Time(17, 0, 0)',
|
||||
' });',
|
||||
' let open = $state(false);',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<TimeRangePicker.Provider',
|
||||
' bind:value bind:open',
|
||||
mode !== 'inline' && ` mode="${mode}"`,
|
||||
granularity !== 'minute' && ` granularity="${granularity}"`,
|
||||
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
|
||||
closeOnRangeComplete && ' closeOnRangeComplete',
|
||||
required && ' required',
|
||||
disabled && ' disabled',
|
||||
readonly && ' readonly',
|
||||
'>',
|
||||
' <TimeRangePicker.Input type="start">…</TimeRangePicker.Input>',
|
||||
' <TimeRangePicker.Input type="end">…<TimeRangePicker.Trigger /></TimeRangePicker.Input>',
|
||||
' <TimeRangePicker.Clock>',
|
||||
' <TimeRangePicker.HourSlider type="start" />',
|
||||
' <TimeRangePicker.MinuteSlider type="start" />',
|
||||
' <TimeRangePicker.HourSlider type="end" />',
|
||||
' <TimeRangePicker.MinuteSlider type="end" />',
|
||||
' </TimeRangePicker.Clock>',
|
||||
'</TimeRangePicker.Provider>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { TimeRangePicker } from '$uix/eidos/components/time-range-picker';",
|
||||
" import { Time, type TimeRange } from '$libs/days';",
|
||||
'',
|
||||
' let value = $state<TimeRange | undefined>({',
|
||||
' start: new Time(9, 0, 0),',
|
||||
' end: new Time(17, 0, 0)',
|
||||
' });',
|
||||
' let open = $state(false);',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<TimeRangePicker',
|
||||
' bind:value bind:open',
|
||||
size !== 'md' && ` size="${size}"`,
|
||||
variant !== 'surface' && ` variant="${variant}"`,
|
||||
color !== 'primary' && ` color="${color}"`,
|
||||
mode !== 'inline' && ` mode="${mode}"`,
|
||||
granularity !== 'minute' && ` granularity="${granularity}"`,
|
||||
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
|
||||
closeOnRangeComplete && ' closeOnRangeComplete',
|
||||
required && ' required',
|
||||
disabled && ' disabled',
|
||||
readonly && ' readonly',
|
||||
lockSeconds && " startReadonlySegments={['second']} endReadonlySegments={['second']}",
|
||||
bounds && ' minValue={new Time(8, 0, 0)} maxValue={new Time(20, 0, 0)}',
|
||||
'>',
|
||||
' <TimeRangePicker.Input type="start">',
|
||||
' {#snippet children({ segments })}',
|
||||
' {#each segments as { part, value }, i (`${part}-${i}`)}',
|
||||
' <TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>',
|
||||
' {/each}',
|
||||
' {/snippet}',
|
||||
' </TimeRangePicker.Input>',
|
||||
' <TimeRangePicker.Input type="end">',
|
||||
' {#snippet children({ segments })}',
|
||||
' {#each segments as { part, value }, i (`${part}-${i}`)}',
|
||||
' <TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>',
|
||||
' {/each}',
|
||||
' <TimeRangePicker.Trigger />',
|
||||
' {/snippet}',
|
||||
' </TimeRangePicker.Input>',
|
||||
' <TimeRangePicker.Portal>',
|
||||
' <TimeRangePicker.Content>',
|
||||
' <TimeRangePicker.Clock>',
|
||||
' <div class="time-range-picker-endpoint">',
|
||||
' <span class="time-range-picker-endpoint-label">Start</span>',
|
||||
' <TimeRangePicker.HourSlider type="start" />',
|
||||
' <TimeRangePicker.MinuteSlider type="start" />',
|
||||
granularity === 'second' && ' <TimeRangePicker.SecondSlider type="start" />',
|
||||
hourCycle === 12 && ' <TimeRangePicker.DayPeriodToggle type="start" />',
|
||||
' </div>',
|
||||
' <div class="time-range-picker-endpoint">',
|
||||
' <span class="time-range-picker-endpoint-label">End</span>',
|
||||
' <TimeRangePicker.HourSlider type="end" />',
|
||||
' <TimeRangePicker.MinuteSlider type="end" />',
|
||||
granularity === 'second' && ' <TimeRangePicker.SecondSlider type="end" />',
|
||||
hourCycle === 12 && ' <TimeRangePicker.DayPeriodToggle type="end" />',
|
||||
' </div>',
|
||||
showFooter &&
|
||||
(showClear || showCancel || showClose) &&
|
||||
' <TimeRangePicker.Footer>',
|
||||
showFooter && showClear && ' <TimeRangePicker.Clear />',
|
||||
showFooter && showCancel && ' <TimeRangePicker.Cancel />',
|
||||
showFooter && showClose && ' <TimeRangePicker.Close />',
|
||||
showFooter &&
|
||||
(showClear || showCancel || showClose) &&
|
||||
' </TimeRangePicker.Footer>',
|
||||
' </TimeRangePicker.Clock>',
|
||||
' </TimeRangePicker.Content>',
|
||||
' </TimeRangePicker.Portal>',
|
||||
'</TimeRangePicker>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Forms · TimeRangePicker</div>
|
||||
<h1 data-uix-page-title>TimeRangePicker</h1>
|
||||
<p data-uix-page-lede>
|
||||
A pair of segmented time inputs (start + end) plus a popover Clock with per-endpoint
|
||||
sliders + AM/PM toggles. Soma owns the range state machine (empty → pending → complete)
|
||||
and the open state; Eidos adds size, variant, intent color, the popover shell and the
|
||||
canonical Footer parts.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill
|
||||
><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span
|
||||
>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{eventRows.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>3</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>3</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area bind:this={stageRef}>
|
||||
<div style="inline-size: min(32rem, 100%);">
|
||||
<TimeRangePicker
|
||||
bind:value
|
||||
bind:placeholder
|
||||
bind:open
|
||||
{mode}
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
startReadonlySegments={readonlySegments}
|
||||
endReadonlySegments={readonlySegments}
|
||||
{granularity}
|
||||
{hourCycle}
|
||||
{locale}
|
||||
{ruler}
|
||||
{closeOnRangeComplete}
|
||||
{size}
|
||||
{variant}
|
||||
{color}
|
||||
minValue={bounds ? minValue : undefined}
|
||||
maxValue={bounds ? maxValue : undefined}
|
||||
>
|
||||
<TimeRangePicker.Label>Working hours</TimeRangePicker.Label>
|
||||
<TimeRangePicker.Input type="start" aria-label="Start time">
|
||||
{#snippet children({ segments }: InputSnippetProps)}
|
||||
{#each segments as { part, value }, i (`${part}-${i}`)}
|
||||
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</TimeRangePicker.Input>
|
||||
<TimeRangePicker.Input type="end" aria-label="End time">
|
||||
{#snippet children({ segments }: InputSnippetProps)}
|
||||
{#each segments as { part, value }, i (`${part}-${i}`)}
|
||||
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
|
||||
{/each}
|
||||
<TimeRangePicker.Trigger />
|
||||
{/snippet}
|
||||
</TimeRangePicker.Input>
|
||||
|
||||
<TimeRangePicker.Portal>
|
||||
<TimeRangePicker.Content>
|
||||
<TimeRangePicker.Clock>
|
||||
<div class="time-range-picker-endpoint">
|
||||
<span class="time-range-picker-endpoint-label">Start</span>
|
||||
<TimeRangePicker.HourSlider type="start" />
|
||||
<TimeRangePicker.MinuteSlider type="start" />
|
||||
{#if granularity === 'second'}
|
||||
<TimeRangePicker.SecondSlider type="start" />
|
||||
{/if}
|
||||
{#if hourCycle === 12}
|
||||
<TimeRangePicker.DayPeriodToggle type="start" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="time-range-picker-endpoint">
|
||||
<span class="time-range-picker-endpoint-label">End</span>
|
||||
<TimeRangePicker.HourSlider type="end" />
|
||||
<TimeRangePicker.MinuteSlider type="end" />
|
||||
{#if granularity === 'second'}
|
||||
<TimeRangePicker.SecondSlider type="end" />
|
||||
{/if}
|
||||
{#if hourCycle === 12}
|
||||
<TimeRangePicker.DayPeriodToggle type="end" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showFooter && (showClear || showCancel || showClose)}
|
||||
<TimeRangePicker.Footer>
|
||||
{#if showClear}<TimeRangePicker.Clear />{/if}
|
||||
{#if showCancel}<TimeRangePicker.Cancel />{/if}
|
||||
{#if showClose}<TimeRangePicker.Close />{/if}
|
||||
</TimeRangePicker.Footer>
|
||||
{/if}
|
||||
</TimeRangePicker.Clock>
|
||||
</TimeRangePicker.Content>
|
||||
</TimeRangePicker.Portal>
|
||||
</TimeRangePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.length === 0}
|
||||
<span>edit, open the clock or play an event to see sema stamps</span>
|
||||
{:else}
|
||||
{#each trace.slice(0, 3) as entry}
|
||||
<span
|
||||
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
|
||||
? ' · ' + entry.intent
|
||||
: ''}</span
|
||||
>
|
||||
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>value</span>
|
||||
{valueLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{eventRows.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
|
||||
>Recipe</button
|
||||
>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
<span data-uix-layer-badge="soma">soma</span> owns the range state machine, segment
|
||||
editing, validation and the open state.
|
||||
<span data-uix-layer-badge="eidos">eidos</span> owns visual treatment.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="soma">soma</span> props · value + behaviour
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>value</span>
|
||||
<span data-uix-chips role="group">
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={value && sameRange(value, businessRange)}
|
||||
onclick={() => setDemoValue('business')}>business</button
|
||||
>
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={value && sameRange(value, lunchRange)}
|
||||
onclick={() => setDemoValue('lunch')}>lunch</button
|
||||
>
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={value === undefined}
|
||||
onclick={() => setDemoValue('empty')}>empty</button
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>mode</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['inline', 'modal'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={mode === v}
|
||||
onclick={() => (mode = v as 'inline' | 'modal')}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>granularity</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['hour', 'minute', 'second'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={granularity === v}
|
||||
onclick={() => (granularity = v as TimeGranularity)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>hour cycle</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each [12, 24] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={hourCycle === v}
|
||||
onclick={() => (hourCycle = v as HourCycle)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>locale</span>
|
||||
<select bind:value={locale}>
|
||||
<option value="en-US">en-US</option>
|
||||
<option value="es-ES">es-ES</option>
|
||||
<option value="fr-FR">fr-FR</option>
|
||||
</select>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>states</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={required} />
|
||||
<span data-uix-switch-label>required</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={disabled} />
|
||||
<span data-uix-switch-label>disabled</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={readonly} />
|
||||
<span data-uix-switch-label>readonly</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>constraints</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={bounds} />
|
||||
<span data-uix-switch-label>min 08:00 · max 20:00</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={lockSeconds} />
|
||||
<span data-uix-switch-label>seconds readonly</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={closeOnRangeComplete} />
|
||||
<span data-uix-switch-label>close on range complete</span>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>clock</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={ruler} />
|
||||
<span data-uix-switch-label>show ruler scale</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Footer composition</div>
|
||||
<p data-uix-section-desc>
|
||||
Each switch decides whether the part is <em>included</em> in the markup. The parts
|
||||
never read these flags — omitting the part hides it, including it always renders.
|
||||
</p>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>visibility</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={showFooter} />
|
||||
<span data-uix-switch-label>render footer</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={showClear} disabled={!showFooter} />
|
||||
<span data-uix-switch-label>clear</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={showCancel} disabled={!showFooter} />
|
||||
<span data-uix-switch-label>cancel</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={showClose} disabled={!showFooter} />
|
||||
<span data-uix-switch-label>close</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['sm', 'md', 'lg'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={size === v}
|
||||
onclick={() => (size = v as TimeRangePickerSize)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>variant</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['surface', 'outline', 'ghost'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={variant === v}
|
||||
onclick={() => (variant = v as TimeRangePickerVariant)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>color</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as v}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={color === v}
|
||||
onclick={() => (color = v as TimeRangePickerColor)}>{v}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="soma">soma</span>
|
||||
<span>headless · provider + sliders</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{somaSnippet}</code></pre>
|
||||
</div>
|
||||
<div data-uix-code style="margin-top: var(--uix-space-3);">
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · root + attached parts</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API + reference comparison</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead>
|
||||
<tr><th>Feature</th><th>React Aria</th><th>Ark</th><th>Chakra</th><th>UIX</th><th>Decision</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Paired segmented inputs</td><td>yes (Date)</td><td>no</td><td>no</td><td>yes</td><td>Soma owns start/end editors.</td></tr>
|
||||
<tr><td class="name">Per-endpoint sliders</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Two slider columns; share generic Slider.</td></tr>
|
||||
<tr><td class="name">Range state machine</td><td>yes (Date)</td><td>n/a</td><td>n/a</td><td>yes</td><td>Empty → pending → complete coordinated in soma.</td></tr>
|
||||
<tr><td class="name">mode='inline' | 'modal'</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Same contract as date / time / color pickers.</td></tr>
|
||||
<tr><td class="name">Footer = composition</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>No *Button root props — pure composition.</td></tr>
|
||||
<tr><td class="name">commit/cancel/clear helpers</td><td>manual</td><td>manual</td><td>manual</td><td>yes</td><td>Wired by Footer parts via picker-shell context.</td></tr>
|
||||
<tr><td class="name">closeOnRangeComplete</td><td>no</td><td>no</td><td>no</td><td>yes</td><td>Auto-dismiss when both endpoints set.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">value / placeholder</td><td>soma</td><td class="default">—</td><td>Bindable <code>TimeRange</code>.</td></tr>
|
||||
<tr><td class="name">open</td><td>soma</td><td class="default">false</td><td>Bindable popover state.</td></tr>
|
||||
<tr><td class="name">mode</td><td>soma</td><td class="default">inline</td><td><code>inline</code> closes on outside-click; <code>modal</code> requires footer action.</td></tr>
|
||||
<tr><td class="name">closeOnRangeComplete</td><td>soma</td><td class="default">false</td><td>Auto-close once both endpoints are set.</td></tr>
|
||||
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default">—</td><td>Validation bounds applied to both endpoints.</td></tr>
|
||||
<tr><td class="name">granularity</td><td>soma</td><td class="default">minute</td><td>Hour / minute / second.</td></tr>
|
||||
<tr><td class="name">hourCycle</td><td>soma</td><td class="default">locale</td><td>12h adds dayPeriod toggle per endpoint.</td></tr>
|
||||
<tr><td class="name">start / endReadonlySegments</td><td>soma</td><td class="default">[]</td><td>Lock segments per endpoint.</td></tr>
|
||||
<tr><td class="name">size / variant / color</td><td>eidos</td><td class="default">md / surface / primary</td><td>Visual-only data attrs.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
TimeRangePicker declares open / dismiss / commit / clear events on its own surface.
|
||||
The underlying TimeRangeField and Popover contribute their own events too.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Owner</th><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th></tr></thead>
|
||||
<tbody>
|
||||
{#each eventRows as row}
|
||||
<tr>
|
||||
<td class="default">{row.owner}</td>
|
||||
<td class="name">{row.action.name}</td>
|
||||
<td class="type">{row.action.semantic.family}</td>
|
||||
<td>{row.action.semantic.verb ?? '—'}</td>
|
||||
<td>{row.action.semantic.sequence ?? 'pre'}</td>
|
||||
<td>{row.action.target}</td>
|
||||
<td>
|
||||
<button
|
||||
data-uix-play
|
||||
onclick={() => {
|
||||
const selector =
|
||||
row.owner === 'TimeRangePicker'
|
||||
? '[data-time-range-picker]'
|
||||
: row.owner === 'TimeRangeField'
|
||||
? '[data-time-range-field]'
|
||||
: '[data-popover-content]';
|
||||
const target = stageRef?.querySelector(
|
||||
selector
|
||||
) as HTMLElement | null;
|
||||
if (!target) return;
|
||||
void uix.events?.emit({
|
||||
name: row.action.name,
|
||||
family: row.action.semantic.family,
|
||||
target
|
||||
});
|
||||
}}>▶ play</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Selectors live in
|
||||
<code>src/uix/eidos/components/time-range-picker/time-range-picker.css</code> and reuse
|
||||
the time-field tokens for the field portion. The clock body lays out two endpoint
|
||||
columns (start / end) and inherits slider chrome from the slider recipe.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name"><code>[data-time-range-picker]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Root visual scope + size/variant/color tokens.</td></tr>
|
||||
<tr><td class="name"><code>[data-time-range-picker-trigger]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Open-clock button. Has embedded variant inside the input.</td></tr>
|
||||
<tr><td class="name"><code>[data-time-range-picker-clock][data-state]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Popover dialog surface. 2-column endpoint grid.</td></tr>
|
||||
<tr><td class="name"><code>[data-time-range-picker-hour-slider][data-endpoint]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Per-endpoint slider wrapper. Composes generic Slider chrome.</td></tr>
|
||||
<tr><td class="name"><code>[data-picker-footer][data-mode]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Footer action row shared via picker-shell.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Dialog</td><td>Clock exposes <code>role="dialog"</code> with an accessible name.</td></tr>
|
||||
<tr><td class="name">Trigger</td><td><code>aria-haspopup="dialog"</code> + <code>aria-expanded</code> + <code>aria-controls</code>.</td></tr>
|
||||
<tr><td class="name">Segments</td><td>Editable segments expose <code>role="spinbutton"</code> with min/max/value per endpoint.</td></tr>
|
||||
<tr><td class="name">Sliders</td><td>Native slider semantics — keyboard arrow/page/home/end per endpoint.</td></tr>
|
||||
<tr><td class="name">Modal</td><td><code>mode='modal'</code> blocks outside-click + Escape; consumer must wire Close/Cancel.</td></tr>
|
||||
<tr><td class="name">Form</td><td>Hidden inputs submit ISO strings for both endpoints when <code>name</code> is set.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue