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
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,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'>;
|
||||
@ -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;
|
||||
@ -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 @@
|
||||
export * from './exports';
|
||||
@ -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,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;
|
||||
}
|
||||
>;
|
||||
@ -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) => 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"><{part.defaultElement}></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…
Reference in new issue