feat(natural-time-picker): daylight-band time picker

24h picker driven by a horizontal daylight gradient — drag the sun/moon
thumb across the night→day→night sky, jump with moment chips, or nudge
with hour/minute/second steppers. Inline panel + trigger/popover variants,
built on the generic Picker (deferValue) composing Slider, Button,
PickerShell, Popover and Tooltip.

- granularity (hour|minute|second) + optional band props; steppers keep a
  fixed 3-column grid; shape/size propagate to panel, trigger and chips.
- fixed-tone sky palette as named recipe tokens (theme-independent).
- fix soma Tooltip.Arrow (empty children slot hid the default SVG) and
  make PickerShell footer labels locale-reactive.
menubar-v4-safe
dev 3 months ago
parent 725b979cb8
commit a2434ea79e

@ -0,0 +1,25 @@
import { getContext, setContext } from 'svelte';
import type { ShapeFamily, Size } from '$uix/eidos/lib/types';
/** The size subset NaturalTimePicker exposes. */
export type NaturalTimePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/**
* Visual context — size + shape propagated from the eidos root to the panel
* (and its composed Buttons). Lives in eidos (the soma layer must not depend on
* eidos visual types). Children adopt the parent's shape by default.
*/
export interface NaturalTimePickerVisual {
getSize: () => NaturalTimePickerSize;
getShape: () => ShapeFamily;
}
const KEY = Symbol('natural-time-picker-visual');
export function setNaturalTimePickerVisual(visual: NaturalTimePickerVisual): void {
setContext(KEY, visual);
}
export function getNaturalTimePickerVisual(): NaturalTimePickerVisual | undefined {
return getContext(KEY) as NaturalTimePickerVisual | undefined;
}

@ -0,0 +1,25 @@
import NaturalTimePickerComponent from './natural-time-picker.svelte';
import Panel from './natural-time-picker-panel.svelte';
import Trigger from './natural-time-picker-trigger.svelte';
import Content from './natural-time-picker-content.svelte';
type NaturalTimePickerNamespace = typeof NaturalTimePickerComponent & {
Panel: typeof Panel;
Trigger: typeof Trigger;
Content: typeof Content;
};
const NaturalTimePicker = NaturalTimePickerComponent as NaturalTimePickerNamespace;
NaturalTimePicker.Panel = Panel;
NaturalTimePicker.Trigger = Trigger;
NaturalTimePicker.Content = Content;
export { NaturalTimePicker };
export default NaturalTimePicker;
export type {
NaturalTimePickerProps,
NaturalTimePickerPanelProps,
NaturalTimePickerTriggerProps,
NaturalTimePickerContentProps
} from './types';

@ -0,0 +1,21 @@
<script lang="ts">
/**
* NaturalTimePicker.Content — the portaled popover content. Wraps the
* composed Popover content and renders the daylight-band Panel inside.
* `data-picker-size` lets the shared PickerShell footer scale.
*/
import * as NaturalTimePicker from '$soma/components/natural-time-picker';
import Panel from './natural-time-picker-panel.svelte';
import type { NaturalTimePickerContentProps } from './types';
let { ...rest }: NaturalTimePickerContentProps = $props();
</script>
<NaturalTimePicker.Content
data-natural-time-picker-content
data-picker-size="sm"
sideOffset={8}
{...rest}
>
<Panel />
</NaturalTimePicker.Content>

@ -0,0 +1,313 @@
<script lang="ts">
/**
* NaturalTimePicker.Panel — the daylight-band picker body. Reads the domain
* coordinator + the generic Picker from context and composes eidos
* primitives (Slider, Button, PickerShell, named lucide icons). The only
* bespoke visual is the band gradient (in the recipe CSS).
*/
import { ActiveEidos } from '$uix/eidos';
import {
Sun,
SunDim,
Sunrise,
Sunset,
Moon,
MoonStar,
Plus,
Minus
} from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { Tooltip } from '$uix/eidos/components/tooltip';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import * as Slider from '$soma/components/slider';
import { NaturalTimePickerProvider } from '$soma/components/natural-time-picker';
import { getNaturalTimePickerVisual } from './context.svelte';
import type { NaturalTimePickerPanelProps } from './types';
let { ...rest }: NaturalTimePickerPanelProps = $props();
const eidos = ActiveEidos.require();
const natTP = NaturalTimePickerProvider.require();
const visual = getNaturalTimePickerVisual();
const size = $derived(visual?.getSize() ?? 'md');
const shape = $derived(visual?.getShape() ?? 'rounded');
const tt = (key: string, fallback: string) =>
eidos.langs.ts(`#?components.natural-time-picker.${key}|${fallback}`);
/**
* The six day segments — the single source for the moment chips, the header
* readout and the band knob, so icon + name always agree. Each maps the sun's
* phase to its icon; labels come from the generic `common.time.period.*`
* catalog (shared by every time component). `jump` is the chip's target hour;
* `from`/`to` resolve the current segment for the header/knob.
*/
const PERIODS = [
{
key: 'dawn',
jump: 7,
from: 6,
to: 8,
icon: Sunrise,
lang: '#?common.time.period.dawn|Amanecer'
},
{
key: 'morning',
jump: 10,
from: 9,
to: 13,
icon: Sun,
lang: '#?common.time.period.morning|Mañana'
},
{
key: 'afternoon',
jump: 15,
from: 14,
to: 17,
icon: SunDim,
lang: '#?common.time.period.afternoon|Tarde'
},
{
key: 'dusk',
jump: 19,
from: 18,
to: 20,
icon: Sunset,
lang: '#?common.time.period.dusk|Atardecer'
},
{
key: 'night',
jump: 22,
from: 21,
to: 23,
icon: Moon,
lang: '#?common.time.period.night|Noche'
},
{
key: 'lateNight',
jump: 3,
from: 0,
to: 5,
icon: MoonStar,
lang: '#?common.time.period.late-night|Madrugada'
}
] as const;
const periodForHour = (hour: number) =>
PERIODS.find((p) => hour >= p.from && hour <= p.to) ?? PERIODS[5];
// Decorative hour ticks: every 3h (00·03·…·21) plus the real last hour (23).
// 24:00 loops back to 00, so the final label is 23 and the tail stays empty.
const ticks = [
...Array.from({ length: 8 }, (_, i) => ({
pos: ((i * 3) / 24) * 100,
label: String(i * 3).padStart(2, '0')
})),
{ pos: (23 / 24) * 100, label: '23' }
];
const disabled = $derived(natTP.opts.disabled.current);
const chipsIconOnly = $derived(natTP.opts.chipsIconOnly.current);
const bandLabel = $derived(tt('band', 'Hora del día'));
// 24h "HH:00" hint for the icon-only chip tooltips.
const startHourLabel = (hour: number) => `${String(hour).padStart(2, '0')}:00`;
const currentHour = $derived(Math.floor(natTP.minutes / 60));
// Current day segment — drives the header readout + the band knob (chips use
// their own period). Capitalised so it can render as a dynamic component.
const currentPeriod = $derived(periodForHour(currentHour));
const CurrentIcon = $derived(currentPeriod.icon);
const currentPeriodLabel = $derived(eidos.langs.ts(currentPeriod.lang));
// AM before noon, PM from noon — drives the meridiem's top/bottom alignment.
const meridiemPeriod = $derived(natTP.minutes < 720 ? 'am' : 'pm');
</script>
<div data-natural-time-picker-panel data-size={size} data-shape={shape} {...rest}>
<!-- Value readout: HH:MM + (sun/moon over day|night) -->
<div data-natural-time-picker-display>
<span data-natural-time-picker-time>
{natTP.displayTime}{#if natTP.meridiem}<span
data-natural-time-picker-meridiem
data-period={meridiemPeriod}>{natTP.meridiem}</span
>{/if}
</span>
{#if natTP.opts.showPeriod.current}
<span data-natural-time-picker-period>
<CurrentIcon size="md" />
{currentPeriodLabel}
</span>
{/if}
</div>
<!-- Daylight band (optional) — vertical position line + sun/moon knob -->
{#if natTP.opts.band.current}
<div data-natural-time-picker-band>
<Slider.Provider
value={[natTP.minutes]}
onValueChange={(v) => natTP.setMinutes(v[0] ?? 0)}
min={0}
max={1439}
step={1}
{disabled}
aria-label={bandLabel}
>
<Slider.Range />
<Slider.Thumb>
<span data-natural-time-picker-knob>
<CurrentIcon size="calc(var(--_ntp-knob) * 0.52)" strokeWidth={2} />
</span>
</Slider.Thumb>
</Slider.Provider>
<div data-natural-time-picker-ticks>
{#each ticks as tick}
<span data-natural-time-picker-tick style="left: {tick.pos}%">{tick.label}</span>
{/each}
</div>
</div>
{/if}
<!-- Steppers: hour (· minute · second per granularity) -->
<div data-natural-time-picker-steppers data-granularity={natTP.inferredGranularity}>
<div data-natural-time-picker-stepper-group data-side="hour">
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('hourMinus', 'Una hora menos')}
onclick={() => natTP.stepHour(-1)}
>
{#snippet icon()}<Minus size="sm" />{/snippet}
</Button>
<span data-natural-time-picker-stepper-label>{eidos.langs.ts('#?common.time.hour|Hora')}</span
>
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('hourPlus', 'Una hora más')}
onclick={() => natTP.stepHour(1)}
>
{#snippet icon()}<Plus size="sm" />{/snippet}
</Button>
</div>
{#if natTP.showMinutes}
<div data-natural-time-picker-stepper-group data-side="minute">
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('minuteMinus', 'Un minuto menos')}
onclick={() => natTP.stepMinute(-1)}
>
{#snippet icon()}<Minus size="sm" />{/snippet}
</Button>
<span data-natural-time-picker-stepper-label
>{eidos.langs.ts('#?common.time.minute|Minuto')}</span
>
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('minutePlus', 'Un minuto más')}
onclick={() => natTP.stepMinute(1)}
>
{#snippet icon()}<Plus size="sm" />{/snippet}
</Button>
</div>
{/if}
{#if natTP.showSeconds}
<div data-natural-time-picker-stepper-group data-side="second">
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('secondMinus', 'Un segundo menos')}
onclick={() => natTP.stepSecond(-1)}
>
{#snippet icon()}<Minus size="sm" />{/snippet}
</Button>
<span data-natural-time-picker-stepper-label
>{eidos.langs.ts('#?common.time.second|Segundo')}</span
>
<Button
iconOnly
variant="surface"
{size}
{shape}
{disabled}
aria-label={tt('secondPlus', 'Un segundo más')}
onclick={() => natTP.stepSecond(1)}
>
{#snippet icon()}<Plus size="sm" />{/snippet}
</Button>
</div>
{/if}
</div>
<!-- Moment chips -->
{#if natTP.opts.showMoments.current}
<div data-natural-time-picker-periods data-icon-only={chipsIconOnly ? '' : undefined}>
{#if chipsIconOnly}
<Tooltip.Group>
{#each PERIODS as period}
<Tooltip>
<Tooltip.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button
{...props}
iconOnly
variant={natTP.isActive(period.jump, 0) ? 'solid' : 'outline'}
color="primary"
{size}
{shape}
{disabled}
onclick={() => natTP.jumpTo(period.jump, 0)}
>
{#snippet icon()}{@const Icon = period.icon}<Icon size="sm" />{/snippet}
{eidos.langs.ts(period.lang)}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content data-natural-time-picker-chip-tip>
<Tooltip.Arrow />
{eidos.langs.ts(period.lang)} · {startHourLabel(period.jump)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
{/each}
</Tooltip.Group>
{:else}
{#each PERIODS as period}
<Button
variant={natTP.isActive(period.jump, 0) ? 'solid' : 'outline'}
color="primary"
{size}
{shape}
{disabled}
onclick={() => natTP.jumpTo(period.jump, 0)}
>
{#snippet icon()}{@const Icon = period.icon}<Icon size="sm" />{/snippet}
{eidos.langs.ts(period.lang)}
</Button>
{/each}
{/if}
</div>
{/if}
<!-- Footer: Borrar / Cancelar / Listo -->
<PickerShell.Footer>
<PickerShell.Clear />
<PickerShell.Cancel />
<PickerShell.Close />
</PickerShell.Footer>
</div>

@ -0,0 +1,35 @@
<script lang="ts">
/**
* NaturalTimePicker.Trigger — a field-style button showing the committed
* value (the "value previo") + a clock icon. Clicking it opens the popover
* with the daylight-band panel. Composes the soma Popover trigger (provided
* by the root's PopoverProvider).
*/
import { ActiveEidos } from '$uix/eidos';
import { Clock } from '$uix/eidos/components/icon';
import * as NaturalTimePicker from '$soma/components/natural-time-picker';
import { NaturalTimePickerProvider } from '$soma/components/natural-time-picker';
import { PickerProvider } from '$soma/components/picker';
import type { TimeValue } from '$libs/days';
import type { NaturalTimePickerTriggerProps } from './types';
import { getNaturalTimePickerVisual } from './context.svelte';
let { placeholder, ...rest }: NaturalTimePickerTriggerProps = $props();
const eidos = ActiveEidos.require();
const natTP = NaturalTimePickerProvider.require();
const picker = PickerProvider.require() as PickerProvider<TimeValue | undefined>;
const visual = getNaturalTimePickerVisual();
const shape = $derived(visual?.getShape() ?? 'rounded');
const display = $derived(natTP.formatValue(picker.opts.value.current));
const resolvedPlaceholder = $derived(
placeholder ?? eidos.langs.ts('#?components.natural-time-picker.placeholder|Seleccionar hora')
);
</script>
<NaturalTimePicker.Trigger data-natural-time-picker-trigger data-shape={shape} {...rest}>
<span data-natural-time-picker-trigger-value data-empty={display ? undefined : ''}>
{display || resolvedPlaceholder}
</span>
<Clock size="sm" data-natural-time-picker-trigger-icon />
</NaturalTimePicker.Trigger>

@ -0,0 +1,327 @@
/* NaturalTimePicker — visual layer.
*
* Everything uses system tokens (surface / content / border / space /
* control-height / radius / shadow; composed <Button>, <PickerShell> own their
* own chrome). The ONLY fixed-tone literals are the daylight band itself — the
* "sky" gradient stops, the white sun/moon knob and the position line — because
* they depict a physical thing (the sky) and must read identically in every
* theme. Same doctrine as the Words rail. Everything else is tokenised.
*/
[data-natural-time-picker-panel] {
/* Fixed-tone sky palette — the band depicts a physical sky (theme-independent,
* same doctrine as the Words rail), so these are NAMED recipe tokens, never
* raw colour literals inline in the rules below. */
--_ntp-sky-night: #1a1d2e;
--_ntp-sky-dawn: #3a4a7a;
--_ntp-sky-gold: #f0b85a;
--_ntp-sky-day: #7fc8e8;
--_ntp-sky-dusk: #c4623a;
--_ntp-ink: #ffffff; /* sun/moon knob — daylight ink */
--_ntp-line: rgb(255 255 255 / 0.9); /* position line */
--_ntp-tick: rgb(255 255 255 / 0.88); /* hour ticks over the gradient */
--_ntp-tick-shadow: rgb(0 0 0 / 0.5); /* tick legibility shadow */
/* Band height + sun-knob diameter, DECOUPLED: the knob is a small node so the
* vertical line reads as the indicator. The band carries one extra space of
* height so the knob has breathing room. Both size-scaled off control height. */
--_ntp-band-block: calc(var(--control-height-md) * 1.7 + var(--space-2));
--_ntp-knob: calc(var(--control-height-md) * 0.62);
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
min-inline-size: var(--popover-min-width-md);
max-inline-size: var(--popover-max-width);
/* Surface = the canonical float (popover) — same padding / radius / overlay
* elevation the composed Popover content uses, not a bespoke surface. */
padding: var(--popover-padding-y-md) var(--popover-padding-x-md);
background: var(--color-surface-overlay);
color: var(--color-content-primary);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--popover-radius);
box-shadow: var(--shadow-overlay);
}
[data-natural-time-picker-panel][data-size='sm'] {
--_ntp-band-block: calc(var(--control-height-sm) * 1.7 + var(--space-2));
--_ntp-knob: calc(var(--control-height-sm) * 0.62);
gap: var(--space-2-5);
min-inline-size: var(--popover-min-width-sm);
padding: var(--popover-padding-y-sm) var(--popover-padding-x-sm);
}
[data-natural-time-picker-panel][data-size='lg'] {
--_ntp-band-block: calc(var(--control-height-lg) * 1.7 + var(--space-2));
--_ntp-knob: calc(var(--control-height-lg) * 0.62);
gap: var(--space-4);
min-inline-size: var(--popover-min-width-lg);
padding: var(--popover-padding-y-lg) var(--popover-padding-x-lg);
}
/* ── Day/night readout — HH:MM + (sun/moon over day|night) ──────────────── */
[data-natural-time-picker-display] {
display: flex;
align-items: center;
gap: var(--space-3);
}
[data-natural-time-picker-time] {
/* inline-flex so the meridiem can align to the top / bottom of the digits. */
display: inline-flex;
align-items: stretch;
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-wide);
line-height: 1;
}
[data-natural-time-picker-panel][data-size='sm'] [data-natural-time-picker-time] {
font-size: var(--font-size-xl);
}
[data-natural-time-picker-meridiem] {
align-self: center;
margin-inline-start: var(--space-1);
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--color-primary-text);
}
/* AM sits at the top of the digits, PM at the bottom. */
[data-natural-time-picker-meridiem][data-period='am'] {
align-self: flex-start;
}
[data-natural-time-picker-meridiem][data-period='pm'] {
align-self: flex-end;
}
/* sun/moon stacked ABOVE the period label — primary-tinted so it stands out
* (swap to --color-tertiary-text / --color-accent-text for a different accent).
* Pushed to the right edge of the header via the auto inline-start margin. */
[data-natural-time-picker-period] {
margin-inline-start: auto;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-primary-text);
text-transform: lowercase;
}
/* ── Daylight band — the bespoke visual ────────────────────────────────── */
[data-natural-time-picker-band] {
position: relative;
inline-size: 100%;
block-size: var(--_ntp-band-block);
border-radius: var(--radius-md);
border: var(--border-width) solid var(--color-border-subtle);
overflow: hidden;
background: linear-gradient(
90deg,
var(--_ntp-sky-night) 0%,
var(--_ntp-sky-night) 18%,
var(--_ntp-sky-dawn) 25%,
var(--_ntp-sky-gold) 33%,
var(--_ntp-sky-day) 50%,
var(--_ntp-sky-gold) 67%,
var(--_ntp-sky-dusk) 76%,
var(--_ntp-sky-night) 83%,
var(--_ntp-sky-night) 100%
);
touch-action: none;
user-select: none;
}
/* The soma Slider owns drag / keyboard / ARIA + the value. The band IS the track
* (the daylight gradient), so the slider fills it transparently with an explicit
* height — the thumb's top:50% must center against the band, but the recipe sizes
* the root to its hit-size (32px), not the band. The panel prefix raises
* specificity to (0,3,0) so this fill wins over the recipe's own
* [data-slider][data-orientation='horizontal'] block-size. */
[data-natural-time-picker-panel] [data-natural-time-picker-band] [data-slider] {
position: absolute;
inset: 0;
block-size: var(--_ntp-band-block);
background: transparent;
border-radius: inherit;
cursor: ew-resize;
}
/* Hide the recipe's own chrome — the track rail (a ::before) and the range fill.
* The gradient is the track; there is no rail or fill to show. */
[data-natural-time-picker-panel] [data-natural-time-picker-band] [data-slider]::before,
[data-natural-time-picker-band] [data-slider-range] {
display: none;
}
/* Thumb = the VERTICAL position line. The soma centers it (left: X%, top: 50%,
* translate(-50%, -50%)); block-size: 100% makes it span the band as a vertical
* bar — the daylight-line indicator from the original design. */
[data-natural-time-picker-band] [data-slider-thumb] {
/* The vertical indicator line — spans the FULL band height (explicit, not
* 100%: a percentage can't resolve against the absolutely-positioned,
* inset-0 track). Flex-centers the knob node on the line. */
block-size: var(--_ntp-band-block);
inline-size: calc(var(--border-width) * 1.5);
background: var(--_ntp-line);
border-radius: var(--radius-sm);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
}
[data-natural-time-picker-band] [data-slider-thumb]:active {
cursor: grabbing;
}
[data-natural-time-picker-band] [data-slider-thumb]:focus-visible {
outline: none;
}
/* Sun/moon knob — a small circle, flex-centered on the line. */
[data-natural-time-picker-knob] {
flex: none;
inline-size: var(--_ntp-knob);
block-size: var(--_ntp-knob);
border-radius: var(--radius-full, 50%);
background: var(--_ntp-ink);
color: var(--_ntp-sky-night);
/* Small knob shadow — NOT the popover overlay shadow, whose huge offset got
* clipped by the band's overflow into a dark horizontal smudge. */
box-shadow: var(--shadow-2);
display: flex;
align-items: center;
justify-content: center;
}
[data-natural-time-picker-band] [data-slider-thumb]:focus-visible [data-natural-time-picker-knob] {
outline: var(--focus-ring-width, 2px) solid var(--color-primary-border);
outline-offset: var(--space-1);
}
/* Hour ticks — INSIDE the band, along the bottom edge (on the night band, so
* a fixed-tone light ink + shadow keeps them legible over the gradient). */
[data-natural-time-picker-ticks] {
position: absolute;
inset-inline: var(--space-2);
inset-block-end: var(--space-1);
block-size: var(--font-size-xxs);
pointer-events: none;
}
[data-natural-time-picker-tick] {
position: absolute;
inset-block-end: 0;
transform: translateX(-50%);
line-height: 1;
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: var(--font-size-xxs);
color: var(--_ntp-tick);
text-shadow: 0 1px 2px var(--_ntp-tick-shadow);
}
/* ── Steppers — hour · minute · second ─────────────────────────────────── */
/* Every group is label-ABOVE its −/+ pair (the DOM stays − label +; grid-areas
* lift the middle label to the top row). The grid is ALWAYS 3 equal columns,
* independent of granularity, so the hour/minute steppers keep the same size +
* position whether or not the later fields show — empty columns just trail. */
[data-natural-time-picker-steppers] {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
[data-natural-time-picker-stepper-group] {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas: 'label label' 'dec inc';
gap: var(--space-1-5);
justify-items: center;
}
[data-natural-time-picker-stepper-group] > :nth-child(1) {
grid-area: dec;
}
[data-natural-time-picker-stepper-group] > :nth-child(2) {
grid-area: label;
}
[data-natural-time-picker-stepper-group] > :nth-child(3) {
grid-area: inc;
}
[data-natural-time-picker-stepper-label] {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-content-muted);
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
}
/* ── Moment chips ──────────────────────────────────────────────────────── */
/* Icon+text: equal-width chips filling the row — a 2-column grid lays the 6
* periods out as three rows of two, wide enough that the label never overflows
* the panel (3 columns squeezed each chip and overran the edge). */
[data-natural-time-picker-periods] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
/* Symmetric breathing room above (from the steppers) and below (the footer). */
margin-block: var(--space-3);
}
/* Icon-only opts out: a single non-wrapping row of natural-size icon chips. */
[data-natural-time-picker-periods][data-icon-only] {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
}
/* Chip text sits one font step below the component — the component font is the
* constant popover type (--font-size-sm); the chips drop to --font-size-xs.
* (0,3,0) beats Button's own size font. */
[data-natural-time-picker-panel] [data-natural-time-picker-periods] [data-button] {
font-size: var(--font-size-xs);
}
/* Icon+text chips stretch to fill their grid cell → equal width across the row.
* (Button is fit-content by default and grid justify can't override that, so the
* inline-size is set explicitly.) Icon-only chips keep their natural size. */
[data-natural-time-picker-panel]
[data-natural-time-picker-periods]:not([data-icon-only])
[data-button] {
inline-size: 100%;
}
/* Icon-only chip tooltips (portaled, so styled globally): always xs type
* regardless of the picker size — the hint must stay small. Surface = the
* default tooltip (its own bg/text contrast); no bespoke colour override. */
[data-tooltip-content][data-natural-time-picker-chip-tip] {
--_tooltip-font-size: var(--font-size-xs);
}
/* ── Trigger — field-style button showing the committed value ──────────── */
[data-natural-time-picker-trigger] {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-inline-size: 11rem;
padding: var(--space-2-5) var(--space-3);
background: var(--color-surface-default);
color: var(--color-content-primary);
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
font: inherit;
font-size: var(--font-size-md);
cursor: pointer;
text-align: start;
transition: border-color var(--duration-fast);
}
[data-natural-time-picker-trigger]:hover {
border-color: var(--color-border-strong);
}
[data-natural-time-picker-trigger][data-state='open'],
[data-natural-time-picker-trigger]:focus-visible {
border-color: var(--color-primary-border);
outline: none;
box-shadow: 0 0 0 var(--border-width) var(--color-primary-border);
}
[data-natural-time-picker-trigger-value] {
font-variant-numeric: tabular-nums;
}
[data-natural-time-picker-trigger-value][data-empty] {
color: var(--color-content-muted);
}
[data-natural-time-picker-trigger-icon] {
margin-inline-start: auto;
color: var(--color-content-muted);
}

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Eidos `<NaturalTimePicker>` — root. Wraps the soma composition provider
* and, by default, renders the inline `<NaturalTimePicker.Panel>`. Pass
* Trigger + Content children for the popover variant. `size` + `shape`
* propagate to the composed Buttons via the visual context (children adopt
* the parent's shape).
*/
import './natural-time-picker.css';
import * as NaturalTimePicker from '$soma/components/natural-time-picker';
import { setPickerShellVisual } from '$uix/eidos/components/picker-shell/visual-context';
import { setNaturalTimePickerVisual } from './context.svelte';
import Panel from './natural-time-picker-panel.svelte';
import type { NaturalTimePickerProps } from './types';
let {
size = 'md',
shape = 'rounded',
value = $bindable(),
open = $bindable(false),
children,
...rest
}: NaturalTimePickerProps = $props();
// Propagate size + shape to the panel's composed Buttons + the footer.
setNaturalTimePickerVisual({ getSize: () => size, getShape: () => shape });
setPickerShellVisual({ getSize: () => size });
</script>
<NaturalTimePicker.Provider {...rest} bind:value bind:open>
{#if children}
{@render children()}
{:else}
<Panel />
{/if}
</NaturalTimePicker.Provider>

@ -0,0 +1,34 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ShapeFamily } from '$uix/eidos/lib/types';
import type { ProviderProps } from '$soma/components/natural-time-picker';
import type { TriggerProps as PopoverTriggerProps, ContentProps as PopoverContentProps } from '$soma/components/popover';
import type { NaturalTimePickerSize } from './context.svelte';
/**
* Props for the eidos `<NaturalTimePicker>` — pass-through of the soma provider
* surface (value / open / deferValue / mode / hourCycle / …). With no children
* it renders `<NaturalTimePicker.Panel>` inline; pass `Trigger` + `Content`
* children for the popover variant.
*/
export type NaturalTimePickerProps = ProviderProps & {
/** Visual scale. @default 'md' */
size?: NaturalTimePickerSize;
/** Corner-shape family, propagated to the composed Buttons. @default 'rounded' */
shape?: ShapeFamily;
};
/** Props for `<NaturalTimePicker.Panel>` — the daylight-band picker body. */
export type NaturalTimePickerPanelProps = HTMLAttributes<HTMLDivElement>;
/**
* Props for `<NaturalTimePicker.Trigger>` — the field-style button showing the
* committed value. Renders its own content (value + clock), so consumer
* `children` are not accepted.
*/
export type NaturalTimePickerTriggerProps = Omit<PopoverTriggerProps, 'children' | 'child'> & {
/** Text shown when no value is committed. @default 'Seleccionar hora' */
placeholder?: string;
};
/** Props for `<NaturalTimePicker.Content>` — the portaled popover content (renders the Panel). */
export type NaturalTimePickerContentProps = Omit<PopoverContentProps, 'children' | 'child'>;

@ -14,7 +14,7 @@
const eidos = ActiveEidos.require();
const shell = getPickerShellHandle();
const visual = getPickerShellVisual();
const label = eidos.langs.ts('#?components.picker-shell.cancel|Cancel');
const label = $derived(eidos.langs.ts('#?components.picker-shell.cancel|Cancel'));
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.cancel();

@ -13,7 +13,7 @@
const eidos = ActiveEidos.require();
const shell = getPickerShellHandle();
const visual = getPickerShellVisual();
const label = eidos.langs.ts('#?components.picker-shell.clear|Clear');
const label = $derived(eidos.langs.ts('#?components.picker-shell.clear|Clear'));
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.clear();

@ -19,7 +19,7 @@
const eidos = ActiveEidos.require();
const shell = getPickerShellHandle();
const visual = getPickerShellVisual();
const label = eidos.langs.ts('#?components.picker-shell.close|Done');
const label = $derived(eidos.langs.ts('#?components.picker-shell.close|Done'));
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
shell?.commit();

@ -87,20 +87,20 @@
stroke-width: 1px;
}
/* ── Entrance / exit — bounce ────────────────────────────────────────────────
/* ── Entrance / exit ─────────────────────────────────────────────────────────
* Tooltip morfo's data-state values: 'closed' | 'delayed-open' | 'instant-open'.
* 'delayed-open' (hover) BOUNCES in; 'instant-open' (keyboard focus) appears with
* no entrance; 'closed' scales out. The bounce scales from `transform-origin`
* (the floating origin, toward the anchor — set above), so it keeps a directional
* feel without per-side keyframes. The tooltip's 3-value state (delayed-open, not
* the generic open/closed) is why this is a bespoke recipe keyframe, not a
* registered `motion` preset. */
* 'delayed-open' (hover) scales in subtly; 'instant-open' (keyboard focus)
* appears with no entrance; 'closed' scales out. The scale grows from
* `transform-origin` (the floating origin, toward the anchor — set above), so it
* keeps a directional feel without per-side keyframes. The tooltip's 3-value
* state (delayed-open, not the generic open/closed) is why this is a bespoke
* recipe keyframe, not a registered `motion` preset. */
[data-tooltip-content][data-state='delayed-open'] {
animation: eidos-tooltip-bounce-in var(--duration-slow) var(--ease-out) both;
animation: eidos-tooltip-enter var(--duration-normal) var(--ease-out) both;
}
[data-tooltip-content][data-state='closed'] {
animation: eidos-tooltip-bounce-out var(--duration-fast) var(--ease-in) both;
animation: eidos-tooltip-exit var(--duration-fast) var(--ease-in) both;
}
/* instant-open: no entrance — element appears at final position. */
@ -109,19 +109,25 @@
transform: none;
}
/* A real bounce (muelle): grow from half size, overshoot clearly past 1, dip
* under, a smaller second overshoot, then settle — a perceptible spring, not a
* subtle pop. `scale` (the individual property, not the `transform` shorthand)
* composes with `transform-origin` and never clobbers a sibling transform. */
@keyframes eidos-tooltip-bounce-in {
0% { opacity: 0; scale: 0.5; }
38% { opacity: 1; scale: 1.18; }
60% { scale: 0.9; }
80% { scale: 1.06; }
100% { scale: 1; }
/* Subtle entrance — a gentle scale + fade from the floating origin, no
* overshoot/bounce. `scale` (the individual property, not the `transform`
* shorthand) composes with `transform-origin` and never clobbers a sibling
* transform. */
@keyframes eidos-tooltip-enter {
from {
opacity: 0;
scale: 0.96;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes eidos-tooltip-bounce-out {
to { opacity: 0; scale: 0.9; }
@keyframes eidos-tooltip-exit {
to {
opacity: 0;
scale: 0.97;
}
}
/* ── Trigger states ─────────────────────────────────────────────────────────

@ -52,7 +52,17 @@ export const commonLangs = {
'day-period': { es: 'AM/PM', en: 'AM/PM' },
'time-zone': { es: 'Zona horaria', en: 'Time zone' },
am: { es: 'AM', en: 'AM' },
pm: { es: 'PM', en: 'PM' }
pm: { es: 'PM', en: 'PM' },
// Day-segment names — generic time vocabulary shared by every time
// component (NaturalTimePicker chips + header, etc.).
period: {
dawn: { es: 'Amanecer', en: 'Dawn' },
morning: { es: 'Mañana', en: 'Morning' },
afternoon: { es: 'Tarde', en: 'Afternoon' },
dusk: { es: 'Atardecer', en: 'Dusk' },
night: { es: 'Noche', en: 'Night' },
'late-night': { es: 'Madrugada', en: 'Late night' }
}
},
field: {
empty: { es: 'Vacío', en: 'Empty' }

@ -45,6 +45,7 @@ import { meterLangs } from './meter';
import { menuDialLangs } from './menu-dial';
import { metricsLangs } from './metrics';
import { monthGridLangs } from './month-grid';
import { naturalTimePickerLangs } from './natural-time-picker';
import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field';
import { onionMenuLangs } from './onion-menu';
@ -141,6 +142,7 @@ export const componentLangs = {
meter: meterLangs,
metrics: metricsLangs,
'month-grid': monthGridLangs,
'natural-time-picker': naturalTimePickerLangs,
'navigation-menu': navigationMenuLangs,
'number-field': numberFieldLangs,
'onion-menu': onionMenuLangs,

@ -0,0 +1,22 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the NaturalTimePicker. Merged under
* `components.natural-time-picker.*` by `ActiveUix` (via the `componentLangs`
* barrel). The eidos parts reference these by absolute idlangref:
* `'#?components.natural-time-picker.{key}|fallback'`.
*
* Generic time vocabulary (hour / minute, the day segments) is NOT duplicated
* here — it lives in `common.time.*` (hour/minute) and `common.time.period.*`
* (dawn/morning/…). Only the component-specific labels live in this catalog.
*/
export const naturalTimePickerLangs = {
band: { es: 'Hora del día', en: 'Time of day' },
placeholder: { es: 'Seleccionar hora', en: 'Select time' },
hourMinus: { es: 'Una hora menos', en: 'One hour earlier' },
hourPlus: { es: 'Una hora más', en: 'One hour later' },
minuteMinus: { es: 'Un minuto menos', en: 'One minute earlier' },
minutePlus: { es: 'Un minuto más', en: 'One minute later' },
secondMinus: { es: 'Un segundo menos', en: 'One second earlier' },
secondPlus: { es: 'Un segundo más', en: 'One second later' }
} satisfies LangNode;

@ -0,0 +1,116 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* NaturalTimePicker — a time picker whose primary control is a 24-hour
* "daylight band": a horizontal spectrum (night → dawn → day → dusk → night)
* with a sun/moon thumb you drag to set the time, plus moment chips
* (Mañana / Mediodía / …) that jump to a canonical hour.
*
* Composed from existing primitives: the generic `Picker` (the transactional
* value host — Listo/Cancelar/Borrar = commit/cancel/clear, deferValue),
* `TimeField` (the segmented input), `Slider` (the daylight band track),
* `Button` (steppers + chips) and `PickerShell` (the footer). The only
* bespoke visual is the daylight gradient itself; everything else uses the
* eidos system.
*
* Open/close are delegated to the composed `Popover` (popover variant), so
* this morfo declares no dialog semantics and no events.
*/
export const naturalTimePickerMorfo = {
name: 'NaturalTimePicker',
kebab: 'natural-time-picker',
scope: ['soma', 'eidos'],
texts: {
label: '#?components.natural-time-picker.label|Time',
trigger: '#?components.natural-time-picker.trigger|Open time picker'
},
events: [],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
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',
ariaBoolean: true
},
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-required',
value: v.propRef('required'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
states: ['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-label',
value: v.translationRef('#?components.natural-time-picker.trigger|Open time picker'),
severity: 'recommended'
}
]
},
{
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
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: [],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
}
]
} as const satisfies Morfo;

@ -37,10 +37,12 @@ export * as Listbox from './listbox';
export * as Menubar from './menubar';
export * as Meter from './meter';
export * as MonthGrid from './month-grid';
export * as NaturalTimePicker from './natural-time-picker';
export * as NavigationMenu from './navigation-menu';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
export * as PasswordField from './password-field';
export * as Picker from './picker';
export * as PickerShell from './picker-shell';
export * as PinInput from './pin-input';
export * as Popover from './popover';

@ -0,0 +1,122 @@
<script lang="ts">
/**
* Soma `NaturalTimePicker.Provider` — composition root. Creates the generic
* `Picker` transaction core (deferValue / commit / cancel / clear), the
* domain coordinator (`NaturalTimePickerProvider`) and a composed `Popover`,
* all bound to one shared time value. The visual panel lives in eidos.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { Time, type TimeValue } from '$libs/days';
import { PickerProvider } from '../../picker/picker-provider.svelte';
import { NaturalTimePickerProvider } from '../natural-time-picker-provider.svelte';
import { PopoverProvider } from '../../popover/popover-provider.svelte';
import type { NaturalTimePickerProviderProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'natural-time-picker'),
value = $bindable(),
onValueChange = () => {},
placeholder = new Time(12, 0),
open = $bindable(false),
onOpenChange = () => {},
onOpenChangeComplete = () => {},
deferValue = true,
mode = 'inline',
hourCycle,
granularity,
minuteStep = 1,
secondStep = 1,
showMoments = true,
showPeriod = true,
band = true,
chipsIconOnly = false,
disabled = false,
readonly = false,
required = false,
locale,
dir,
ref = $bindable(null),
children,
child,
...restProps
}: NaturalTimePickerProviderProps = $props();
const sharedValue = writableActive<TimeValue | undefined>(
() => value,
(v) => {
value = v as never;
onValueChange(v);
}
);
const sharedOpen = writableActive(
() => open,
(v) => {
open = v;
onOpenChange(v);
}
);
// ── Transaction core — no DOM ref; context + workingValue + footer handle ──
const picker = PickerProvider.create<TimeValue | undefined>({
id: readableActive(() => `${id}-picker`),
value: sharedValue,
open: sharedOpen,
deferValue: readableActive(() => deferValue),
mode: readableActive(() => mode),
// Clear: deferred → revert the draft to the bound value; live → set 00:00.
emptyValue: readableActive(() => (deferValue ? value : new Time(0, 0))),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
validate: readableActive(() => undefined),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});
// ── Domain coordinator over the working (draft-or-bound) value ─────────────
const state = NaturalTimePickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: picker.workingValue,
open: sharedOpen,
placeholder: readableActive(() => placeholder),
hourCycle: readableActive(() => hourCycle),
granularity: readableActive(() => granularity),
minuteStep: readableActive(() => minuteStep),
secondStep: readableActive(() => secondStep),
showMoments: readableActive(() => showMoments),
showPeriod: readableActive(() => showPeriod),
band: readableActive(() => band),
chipsIconOnly: readableActive(() => chipsIconOnly),
locale: readableActive(() => locale),
dir: readableActive(() => dir ?? 'ltr'),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => picker.isInvalid)
});
// ── Composed Popover (popover variant; inline panels ignore it) ────────────
PopoverProvider.create({
id: readableActive(() => `${id}-popover`),
open: sharedOpen,
onOpenChangeComplete: readableActive(() => onOpenChangeComplete),
modal: readableActive(() => mode === 'modal')
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,16 @@
// NaturalTimePicker — daylight-band time picker. Composition root + domain
// coordinator + composed Popover surface. The visual panel lives in eidos.
export { default as Provider } from './components/natural-time-picker.svelte';
export {
NaturalTimePickerProvider,
naturalTimePickerAttrs,
type NaturalTimePickerOpts,
type NaturalTimePickerMoment
} from './internals';
// Popover surface (shared via the composed PopoverProvider).
export { Trigger, Content, Arrow, Close, Overlay } from '../popover/exports';
export type { NaturalTimePickerProviderProps as ProviderProps } from './types';

@ -0,0 +1,8 @@
// Indirection layer for the public barrel (no direct *provider.svelte.ts
// re-export from exports.ts).
export {
NaturalTimePickerProvider,
naturalTimePickerAttrs,
type NaturalTimePickerOpts,
type NaturalTimePickerMoment
} from './natural-time-picker-provider.svelte';

@ -0,0 +1,242 @@
import { context, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { writableActive, type ActiveProps, type State, type StateProps } from '$libs/reactive';
import type { Direction } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { Time, type TimeValue, type HourCycle, type TimeGranularity } from '$libs/days';
import { naturalTimePickerMorfo } from '../../../morfo/components/natural-time-picker';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
const attrs = createAttrs(naturalTimePickerMorfo);
const MINUTES_PER_DAY = 1440;
/** Daylight window — the hours rendered as "day" (sun) vs "night" (moon). */
const DAY_START_HOUR = 6;
const DAY_END_HOUR = 19; // exclusive
/** Coordinates of the daylight band thumb + period readout. */
export interface NaturalTimePickerMoment {
hour: number;
minute: number;
}
export interface NaturalTimePickerOpts
extends
ProviderOpts,
StateProps<{
/** The working time (the generic Picker's draft-or-bound ref). */
value: TimeValue | undefined;
}>,
ActiveProps<{
open: boolean;
/** Fallback time used for the band/display when `value` is undefined. */
placeholder: TimeValue;
hourCycle: HourCycle | undefined;
/** Time granularity — `'second'` reveals the seconds field + a third stepper. */
granularity: TimeGranularity | undefined;
/** Minutes added/removed per tap of the minute steppers. */
minuteStep: number;
/** Seconds added/removed per tap of the second steppers. */
secondStep: number;
/** Whether the moment chips (Mañana / Mediodía / …) are shown. */
showMoments: boolean;
/** Whether the header day/night readout (sun/moon + label) is shown. */
showPeriod: boolean;
/** Whether the daylight band (the gradient slider) is shown. */
band: boolean;
/** Whether the moment chips render icon-only (with a tooltip). */
chipsIconOnly: boolean;
locale: string | undefined;
dir: Direction;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
}> {}
/**
* Domain coordinator for NaturalTimePicker. Owns the time ⇆ minutes bridge
* the daylight-band Slider consumes, the day/night derivation, the moment
* jumps and the hour/minute steppers. The transaction (commit / cancel /
* clear, deferValue) lives in the composed generic `PickerProvider`; this
* class only edits the working value it is handed.
*/
export class NaturalTimePickerProvider {
readonly opts: NaturalTimePickerOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<NaturalTimePickerProvider>('NaturalTimePicker');
static get(): NaturalTimePickerProvider | undefined {
return this.ctx.getOr(undefined) as NaturalTimePickerProvider | undefined;
}
static require(): NaturalTimePickerProvider {
return this.ctx.get();
}
static create(opts: NaturalTimePickerOpts) {
return new NaturalTimePickerProvider(opts);
}
private constructor(opts: NaturalTimePickerOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(naturalTimePickerMorfo, {
states: { open: () => this.opts.open.current },
props: {
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
required: () => this.opts.required.current,
invalid: () => this.opts.invalid.current
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: NaturalTimePickerProvider.ctx
});
}
// ── Time ⇆ minutes ────────────────────────────────────────────────────────
private get locked(): boolean {
return this.opts.disabled.current || this.opts.readonly.current;
}
/** Effective time — the working value, or the placeholder when unset. */
readonly effectiveTime = $derived.by<TimeValue>(
() => this.opts.value.current ?? this.opts.placeholder.current
);
/** Minutes since midnight for the effective time (0…1439). */
readonly minutes = $derived.by(
() => this.effectiveTime.hour * 60 + this.effectiveTime.minute
);
/**
* The value the daylight-band Slider binds to (single-thumb → `[minutes]`).
* Writing it commits a whole new `Time`.
*/
readonly sliderValue: State<number[]> = writableActive<number[]>(
() => [this.minutes],
(arr) => this.setMinutes(arr[0] ?? 0)
);
/** Replace the working value with the `Time` for a minutes-of-day count. */
setMinutes(total: number): void {
if (this.locked) return;
const m = ((Math.round(total) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
// Preserve seconds — dragging the hours axis must not reset them.
this.opts.value.current = this.effectiveTime.set({ hour: Math.floor(m / 60), minute: m % 60 } as never) as TimeValue;
}
/** Step the hour by ±1 (wraps within the day). */
stepHour(direction: 1 | -1): void {
this.setMinutes(this.minutes + direction * 60);
}
/** Step the minute by ±`minuteStep` (wraps within the day). */
stepMinute(direction: 1 | -1): void {
this.setMinutes(this.minutes + direction * this.opts.minuteStep.current);
}
/** Step the second by ±`secondStep` (wraps 0–59; never rolls into the minute). */
stepSecond(direction: 1 | -1): void {
if (this.locked) return;
const s = (((this.second + direction * this.opts.secondStep.current) % 60) + 60) % 60;
this.opts.value.current = this.effectiveTime.set({ second: s } as never) as TimeValue;
}
/** Jump to a canonical moment (used by the moment chips). */
jumpTo(hour: number, minute: number): void {
if (this.locked) return;
this.opts.value.current = new Time(hour, minute) as TimeValue;
}
/** Whether the effective time matches a moment exactly (drives chip selected state). */
isActive(hour: number, minute: number): boolean {
const v = this.opts.value.current;
if (!v) return false;
return v.hour === hour && v.minute === minute;
}
// ── Day / night ───────────────────────────────────────────────────────────
/** True while the effective time falls inside the daylight window. */
readonly isDay = $derived.by(() => {
const h = this.effectiveTime.hour;
return h >= DAY_START_HOUR && h < DAY_END_HOUR;
});
// ── Display ───────────────────────────────────────────────────────────────
/** Resolved clock: explicit prop → soma dates → 24h. */
readonly hourCycle = $derived.by<HourCycle>(
() => this.opts.hourCycle.current ?? this.soma.dates?.getHourCycle() ?? 24
);
private pad(n: number): string {
return String(n).padStart(2, '0');
}
/** Hour rendered for the active clock (12h → 1…12, 24h → 0…23). */
readonly displayHour = $derived.by(() => {
const h = this.effectiveTime.hour;
if (this.hourCycle === 24) return this.pad(h);
const x = h % 12;
return this.pad(x === 0 ? 12 : x);
});
readonly displayMinute = $derived.by(() => this.pad(this.effectiveTime.minute));
/** Resolved granularity (default 'minute'); drives the visible fields/steppers. */
readonly inferredGranularity = $derived.by<TimeGranularity>(
() => this.opts.granularity.current ?? 'minute'
);
/** Whether the minute field + stepper are shown (granularity ≠ 'hour'). */
readonly showMinutes = $derived.by(() => this.inferredGranularity !== 'hour');
/** Whether the seconds field + stepper are shown (granularity 'second'). */
readonly showSeconds = $derived.by(() => this.inferredGranularity === 'second');
readonly second = $derived.by(() => (this.effectiveTime as unknown as { second: number }).second);
readonly displaySecond = $derived.by(() => this.pad(this.second));
/** AM / PM marker for 12h clocks; empty for 24h. */
readonly meridiem = $derived.by(() => {
if (this.hourCycle === 24) return '';
return this.effectiveTime.hour >= 12 ? 'PM' : 'AM';
});
/** `HH:MM`(`:SS`) formatted for the active clock (no meridiem). */
readonly displayTime = $derived.by(() => {
if (!this.showMinutes) return this.displayHour;
if (this.showSeconds) return `${this.displayHour}:${this.displayMinute}:${this.displaySecond}`;
return `${this.displayHour}:${this.displayMinute}`;
});
/**
* Format an arbitrary time for the active clock (used by the trigger to
* show the committed value). Returns `''` for an unset value.
*/
formatValue(value: TimeValue | undefined): string {
if (!value) return '';
const h12 = value.hour % 12 === 0 ? 12 : value.hour % 12;
const h = this.hourCycle === 24 ? this.pad(value.hour) : this.pad(h12);
const mer = this.hourCycle === 24 ? '' : ` ${value.hour >= 12 ? 'PM' : 'AM'}`;
if (!this.showMinutes) return `${h}${mer}`;
const sec = this.showSeconds
? `:${this.pad((value as unknown as { second: number }).second)}`
: '';
return `${h}:${this.pad(value.minute)}${sec}${mer}`;
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.renderProps()
} as const)
);
}
export { attrs as naturalTimePickerAttrs };

@ -0,0 +1,72 @@
import type { Snippet } from 'svelte';
import type { WithChild, OnChangeFn, Direction, PrimitiveDivAttributes } from '../../types';
import type { TimeValue, HourCycle, TimeGranularity } from '$libs/days';
import type { PickerShellMode } from '../picker-shell';
/**
* Props for the root `NaturalTimePicker.Provider`. Creates the generic
* `Picker` transaction core + the domain coordinator + a composed `Popover`
* (popover variant), all sharing one time value.
*/
export type NaturalTimePickerProviderProps = WithChild<
Omit<PrimitiveDivAttributes, 'value' | 'placeholder'> & {
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Children snippet. */
children?: Snippet;
// ── Value ──
/** Current time. Bindable. */
value?: TimeValue;
/** Called when the bound `value` changes (on Listo in deferred mode). */
onValueChange?: OnChangeFn<TimeValue | undefined>;
/** Fallback time shown on the band/display while `value` is unset. @default 12:00 */
placeholder?: TimeValue;
// ── Open state ──
/** Whether the popover is open (popover variant). Bindable. @default false */
open?: boolean;
/** Called when open state changes. */
onOpenChange?: OnChangeFn<boolean>;
/** Called after the open/close animation completes. */
onOpenChangeComplete?: OnChangeFn<boolean>;
// ── Transaction ──
/** Buffer edits until Listo (commit). @default true */
deferValue?: boolean;
/** Popover dismissal mode. @default 'inline' */
mode?: PickerShellMode;
// ── Format ──
/** 12 or 24 hour clock. Falls back to soma dates → 24. */
hourCycle?: HourCycle;
/** Time granularity. `'second'` shows the seconds field + a third stepper. @default 'minute' */
granularity?: TimeGranularity;
/** Minutes per tap of the minute steppers. @default 1 */
minuteStep?: number;
/** Seconds per tap of the second steppers (granularity 'second'). @default 1 */
secondStep?: number;
/** Show the moment chips (Mañana / Mediodía / Tarde / Noche / Madrugada). @default true */
showMoments?: boolean;
/** Show the header day/night readout (sun/moon icon + día/noche label). @default true */
showPeriod?: boolean;
/** Show the daylight band (the gradient slider). @default true */
band?: boolean;
/** Render the moment chips icon-only, with a tooltip (period name + start hour). @default false */
chipsIconOnly?: boolean;
// ── State flags ──
/** Disable interaction. @default false */
disabled?: boolean;
/** Read-only: focusable but not editable. @default false */
readonly?: boolean;
/** Whether the form control is required. @default false */
required?: boolean;
// ── i18n ──
/** Override locale. Falls back to `soma.langs.getLocale()`. */
locale?: string;
/** Direction. Falls back to `'ltr'`. */
dir?: Direction;
}
>;

@ -34,8 +34,10 @@
{#if child}
{@render child({ props: mergedProps })}
{:else}
{:else if children}
<Arrow {...mergedProps} {width} {height}>
{@render children?.()}
{@render children()}
</Arrow>
{:else}
<Arrow {...mergedProps} {width} {height} />
{/if}

@ -0,0 +1,543 @@
<script lang="ts">
/**
* NaturalTimePicker — daylight-band time picker demo (v2 layout). The
* primary surface is the trigger (committed value) → popover panel; an
* inline panel is shown too. Built on the generic Picker; the only bespoke
* visual is the daylight gradient.
*/
import { NaturalTimePicker } from '$uix/eidos/components/natural-time-picker';
import { SHAPE_FAMILIES, type ShapeFamily } from '$uix/eidos/lib/types';
import { compileMorfo } from '$uix/morfo';
import { naturalTimePickerMorfo } from '@/uix/morfo/components/natural-time-picker';
import { getActiveUix } from '$active-uix';
import { Time, type TimeValue, type HourCycle } from '$libs/days';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Live (component API) state ──────────────────────────────────────────
let value = $state<TimeValue | undefined>(new Time(7, 50));
let open = $state(false);
let inlineValue = $state<TimeValue | undefined>(new Time(15, 30));
let deferValue = $state(true);
let mode = $state<'inline' | 'modal'>('inline');
let hourCycle = $state<HourCycle>(24);
let granularity = $state<'hour' | 'minute' | 'second'>('minute');
let minuteStep = $state(1);
let secondStep = $state(1);
let showMoments = $state(true);
let showPeriod = $state(true);
let band = $state(true);
let chipsIconOnly = $state(false);
let disabled = $state(false);
let readonly = $state(false);
let size = $state<'sm' | 'md' | 'lg'>('md');
let shape = $state<ShapeFamily>('rounded');
// ── System (foundation) axes — applied to the stage ─────────────────────
let density = $state('comfortable');
let scaling = $state(1);
let sysMode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
const stageStyle = $derived(`--scaling: ${scaling}; --border-width: ${borderWidth}px;`);
// ── Trace + morfo ───────────────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
const compiled = compileMorfo(naturalTimePickerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const actions = $derived([...compiled.actions.byName.values()]);
function pad(n: number): string {
return String(n).padStart(2, '0');
}
const boundLabel = $derived(value ? `${pad(value.hour)}:${pad(value.minute)}` : '— sin valor —');
function setDemoValue(kind: 'morning' | 'noon' | 'empty'): void {
if (kind === 'empty') value = undefined;
else if (kind === 'morning') value = new Time(7, 50);
else value = new Time(12, 0);
}
// ── Snippet (parity with the live preview) ──────────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { NaturalTimePicker } from '$uix/eidos/components/natural-time-picker';",
" import { Time, type TimeValue } from '$libs/days';",
'',
' let value = $state<TimeValue | undefined>(new Time(7, 50));',
' let open = $state(false);',
'</' + 'script>',
'',
'<NaturalTimePicker',
' bind:value bind:open',
!deferValue && ' deferValue={false}',
mode !== 'inline' && ` mode="${mode}"`,
hourCycle !== 24 && ` hourCycle={${hourCycle}}`,
granularity !== 'minute' && ` granularity="${granularity}"`,
minuteStep !== 1 && ` minuteStep={${minuteStep}}`,
granularity === 'second' && secondStep !== 1 && ` secondStep={${secondStep}}`,
!band && ' band={false}',
!showMoments && ' showMoments={false}',
disabled && ' disabled',
readonly && ' readonly',
'>',
' <NaturalTimePicker.Trigger />',
' <NaturalTimePicker.Content />',
'</NaturalTimePicker>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Time · NaturalTimePicker</div>
<h1 data-uix-page-title>Natural time picker</h1>
<p data-uix-page-lede>
A time picker driven by a 24-hour <strong>daylight band</strong>: drag the sun/moon
thumb across the night→day→night spectrum, jump with the moment chips, or nudge with
the hour (left) / minute (right) steppers. The trigger shows the committed value and
opens a popover; an inline panel is also available. Built on the generic
<code>Picker</code> (Listo / Cancelar / Borrar = commit / cancel / clear,
<code>deferValue</code>), composing <code>Slider</code>, <code>Button</code> and
<code>PickerShell</code>. The only bespoke visual is the daylight gradient.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>value</span>{boundLabel}</span>
<span data-uix-meta-pill><span data-uix-meta-key>clock</span>{hourCycle}h</span>
<span data-uix-meta-pill><span data-uix-meta-key>defer</span>{String(deferValue)}</span>
</div>
</header>
<!-- Live preview with the System axes applied to the stage -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={sysMode === 'inherit' ? undefined : sysMode}
data-mode={sysMode === 'inherit' ? undefined : sysMode}
{dir}
style={stageStyle}
>
<div style="display: flex; flex-wrap: wrap; gap: var(--uix-space-8); align-items: flex-start; justify-content: center; padding: var(--uix-space-4);">
<div style="display: flex; flex-direction: column; gap: var(--uix-space-2);">
<span data-uix-control-label>trigger + popover</span>
<NaturalTimePicker
bind:value
bind:open
{size}
{shape}
{deferValue}
{mode}
{hourCycle}
{granularity}
{minuteStep}
{secondStep}
{showMoments}
{showPeriod}
{band}
{chipsIconOnly}
{disabled}
{readonly}
>
<NaturalTimePicker.Trigger />
<NaturalTimePicker.Content />
</NaturalTimePicker>
</div>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-2);">
<span data-uix-control-label>inline panel</span>
<NaturalTimePicker
bind:value={inlineValue}
{size}
{shape}
{deferValue}
{hourCycle}
{granularity}
{minuteStep}
{secondStep}
{showMoments}
{showPeriod}
{band}
{chipsIconOnly}
{disabled}
{readonly}
/>
</div>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>valor (form)</span>
<strong style="font-variant-numeric: tabular-nums;">{boundLabel}</strong>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>open</span> {String(open)} ·
<span data-uix-stage-trace-key>clock</span> {hourCycle}h
</span>
</div>
</div>
<!-- Tabs -->
<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 === 'system'} onclick={() => (tab = 'system')}>
<span data-uix-layer-badge="eidos">eidos</span> System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>Motion</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>{actions.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>Services</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 · {actions.length}e</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>
The component API. Foundation axes (density, scaling, mode, dir, border) live in the
<button data-uix-linklike onclick={() => (tab = 'system')}>System</button> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> value + transaction
</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?.hour === 7 && value?.minute === 50} onclick={() => setDemoValue('morning')}>07:50</button>
<button data-uix-chip data-active={value?.hour === 12 && value?.minute === 0} onclick={() => setDemoValue('noon')}>12:00</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>deferValue <span data-uix-control-hint>draft until Listo</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={deferValue} />
<span data-uix-switch-label>{deferValue ? 'deferred' : 'live'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>mode <span data-uix-control-hint>popover dismissal</span></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>hourCycle</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}h</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>granularity <span data-uix-control-hint>hour · minute · second fields</span></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 'hour' | 'minute' | 'second')}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>minuteStep</span>
<span data-uix-chips role="radiogroup">
{#each [1, 5, 10, 15] as v}
<button data-uix-chip data-active={minuteStep === v} onclick={() => (minuteStep = v)}>{v}</button>
{/each}
</span>
</label>
{#if granularity === 'second'}
<label data-uix-control>
<span data-uix-control-label>secondStep</span>
<span data-uix-chips role="radiogroup">
{#each [1, 5, 10, 15] as v}
<button data-uix-chip data-active={secondStep === v} onclick={() => (secondStep = v)}>{v}</button>
{/each}
</span>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label>chips <span data-uix-control-hint>moment shortcuts</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showMoments} />
<span data-uix-switch-label>{showMoments ? 'shown' : 'hidden'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>period <span data-uix-control-hint>day/night readout</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showPeriod} />
<span data-uix-switch-label>{showPeriod ? 'shown' : 'hidden'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>band <span data-uix-control-hint>daylight gradient</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={band} />
<span data-uix-switch-label>{band ? 'shown' : 'hidden'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>chips icon-only <span data-uix-control-hint>tooltip: name + start hour</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={chipsIconOnly} />
<span data-uix-switch-label>{chipsIconOnly ? 'icons' : 'icon + text'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>states</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>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual · size + shape
</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 'sm' | 'md' | 'lg')}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>shape <span data-uix-control-hint>chips adopt it</span></span>
<span data-uix-chips role="radiogroup">
{#each SHAPE_FAMILIES as v}
<button data-uix-chip data-active={shape === v} onclick={() => (shape = v)}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root + trigger + content</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="eidos">eidos</span> System axes</h2>
<p data-uix-section-desc>
Foundation knobs that are NOT component props, applied live to the stage. Try
<strong>dark</strong> mode — the daylight band keeps its fixed-tone sky (it depicts a
physical thing), while the panel chrome retints with the theme.
</p>
<SystemAxes bind:density bind:scaling bind:mode={sysMode} bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion catalog</h2>
<p data-uix-section-desc>
The motion system. NaturalTimePicker has no <code>motion</code> prop — the popover
open/close animation comes from the composed Popover, and the thumb tracks the pointer
directly. Below is the catalog the system ships.
</p>
<MotionPanel note="Reduced motion is honored for free: presets declare a reduce policy and the engine emits a @media (prefers-reduced-motion) block." />
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> firma + sound</h2>
<p data-uix-section-desc>
NaturalTimePicker declares <strong>0 events of its own</strong> — open / close are
delegated to the composed <code>Popover</code> (which IS the dialog) and the
accept / cancel / clear perception flows through the generic <code>Picker</code> +
<code>PickerShell</code>. So the firma table is intentionally empty here; the
perceptual events live on those composed components' demos.
</p>
<SemaPanel {actions} {uix} boundIntent="neutral" getTarget={() => stageRef?.querySelector('[data-natural-time-picker]') ?? stageRef} />
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
Consumes <strong>langs</strong> (i18n): the morfo declares translatable
<code>texts</code>, and the panel labels (Mañana / Mediodía / día / noche / Borrar /
Cancelar / Listo) resolve through <code>uix.langs</code> with literal fallbacks. The
hour cycle follows <code>soma.dates</code> when <code>hourCycle</code> is unset.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Text key</th><th>Default</th><th>Service</th></tr></thead>
<tbody>
<tr><td class="name">label</td><td class="type">{naturalTimePickerMorfo.texts?.label ?? '—'}</td><td>langs</td></tr>
<tr><td class="name">trigger</td><td class="type">{naturalTimePickerMorfo.texts?.trigger ?? '—'}</td><td>langs</td></tr>
<tr><td class="name">moment.* / day / night</td><td class="type">#?components.natural-time-picker.* (fallbacks inline)</td><td>langs</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">TimeValue | undefined</td><td class="default empty">—</td><td>Current time.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(v) =&gt; void</td><td class="default empty">—</td><td>Fires on commit (deferred) or every edit (live).</td></tr>
<tr><td class="name">placeholder</td><td class="type">TimeValue</td><td class="default">12:00</td><td>Band/display anchor when value is unset.</td></tr>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Popover state.</td></tr>
<tr><td class="name">deferValue</td><td class="type">boolean</td><td class="default">true</td><td>Buffer edits until Listo (commit); else write the bound value live.</td></tr>
<tr><td class="name">mode</td><td class="type">'inline' | 'modal'</td><td class="default">'inline'</td><td>Popover dismissal — modal blocks outside-click + Escape.</td></tr>
<tr><td class="name">hourCycle</td><td class="type">12 | 24</td><td class="default empty">soma dates → 24</td><td>Display clock; 12 adds AM/PM.</td></tr>
<tr><td class="name">minuteStep</td><td class="type">number</td><td class="default">1</td><td>Minutes per minute-stepper tap.</td></tr>
<tr><td class="name">showMoments <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Show the moment chips (Mañana / Mediodía / …).</td></tr>
<tr><td class="name">disabled / readonly / required</td><td class="type">boolean</td><td class="default">false</td><td>Interaction state.</td></tr>
<tr><td class="name">locale / dir</td><td class="type">string / 'ltr' | 'rtl'</td><td class="default empty">soma config</td><td>i18n.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<p data-uix-section-desc>
<code>NaturalTimePicker</code> (root) renders <code>Panel</code> inline by default;
<code>Trigger</code> + <code>Content</code> opt into the popover variant. Footer is the
shared <code>PickerShell.Clear</code> / <code>.Cancel</code> / <code>.Close</code>.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/natural-time-picker.ts</code>. Open / close are
delegated to the composed Popover, so this morfo declares the provider + trigger +
content surface and <strong>no events</strong>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{naturalTimePickerMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{naturalTimePickerMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{naturalTimePickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{naturalTimePickerMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{naturalTimePickerMorfo.events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</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.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</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 at <code>src/uix/eidos/components/natural-time-picker/natural-time-picker.css</code>.
Everything uses system tokens (surface / content / border / space / radius). The ONE
bespoke value is the daylight gradient — fixed-tone literal stops (only the night
endpoint flips light/dark), the same doctrine as the Words rail.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-natural-time-picker-panel]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-natural-time-picker-band] · the daylight gradient</td><td><span data-uix-tag data-kind="eidos">eidos</span> (fixed-tone)</td></tr>
<tr><td class="name">[data-natural-time-picker-band] [data-slider-thumb] · sun/moon</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-natural-time-picker-trigger][data-state]</td><td><span data-uix-tag data-kind="soma">morfo</span> + <span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-picker-footer] (PickerShell)</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The trigger is a button with <code>aria-haspopup="dialog"</code> / <code>aria-expanded</code>;
the popover is the composed <strong>Popover</strong> (WAI-ARIA dialog). The band is a
<a href="https://www.w3.org/WAI/ARIA/apg/patterns/slider/" target="_blank" rel="noreferrer">slider</a>
(arrow keys step; Home/End jump). Steppers + chips are buttons.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span> on Trigger</td><td>Open the popover.</td></tr>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span> on band</td><td>Step the time by one minute.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span> on band</td><td>Jump to 00:00 / 23:59.</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>inline mode: dismiss (discards the draft in deferred mode).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.