feat(uix): calendar-nav unification, TimePicker datetime blocks, A31 perf

Calendar chrome:
- Shared CalendarNav context — Calendar/RangeCalendar/DateRangePicker reuse the
  refined month/year selects + prev/next buttons; deleted the range pickers'
  bespoke month/year selects. New calendar-nav-button, calendar-select.css,
  visual-context, soma calendar-nav.

TimePicker (datetime building blocks):
- DayPeriodToggle composes the system ToggleGroup (was native radios):
  type=single, deselectable=false, solid/primary, right-aligned.
- Sliders self-hide per granularity (hour -> only hour); ruler -> showScales;
  slider labels follow the canonical label scale per size.
- Active-toggle hover fixes: pin bg to the resting fill (no wash) + archetypes
  hover rule excludes data-state=on (it was flipping a pressed toggle's text to
  content-primary / black in light themes).
- DatePicker demo: locale-derived hour cycle + composed TimePicker time row.

Perf (audit A31): lifted selection/checked sets ($derived Set, O(1) .has()) across
checkbox, combobox, grid-list, listbox, select, tag-group, toggle-group, tree-grid,
tree-view providers.

Popover: fix trigger re-click bounce (exclude trigger node in onInteractOutside).

langs: picker-shell footer strings. CONTINUE.md hand-off for the next-session
DateTimePicker refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 3 months ago
parent 25ce77c142
commit 92f988e7e7

@ -0,0 +1,65 @@
# CONTINUE — hand-off (2026-06-26 → next session)
Branch `active-uix`. This session's work is **committed + pushed**. Pick up the
**DateTimePicker refactor** next.
## Next task — shared "time-in-popover" for DatePicker + DateRangePicker
Full plan: `C:\Users\dev\.claude\plans\woolly-knitting-fairy.md` (approved).
Decisions locked with the user: composable `<DatePicker.Time />` part (not
auto-rendered) · DateRangePicker shows **start + end** (two time pickers).
Goal: lift the ad-hoc time editor out of the DatePicker **demo** into a
**shared, reusable mechanism**, then give DateRangePicker the same. DatePicker
becomes effectively a DateTimePicker (granularity reaching `hour|minute|second`
surfaces the time editor); the **hour cycle derives from `locale`** when
`hourCycle` is unset (en-US → 12h, es-ES → 24h), for input **and** time editor.
Both soma providers already plumb `granularity` + `hourCycle`; the missing piece
is the popover time editor (today hand-composed in the date-picker demo).
Execution order (see plan §1–6):
1. `picker-shell/picker-time.ts` (new) — pure helpers: `localeHourCycle`,
`granularityHasTime`, `timeOf`, `withTimeOn`, `withoutTime` (reuse `$libs/days`).
2. `picker-shell/picker-time-row.svelte` (new) — value-agnostic time row (knows
only `TimeValue`); self-hides when granularity has no time; composes the
`<TimePicker>` namespace; owns the relocated time-row CSS.
3. Both `*-picker.svelte` roots derive `hourCycle ?? localeHourCycle(locale)` →
pass to the soma Provider (input honours it); publish a bridge context
(value get/set + granularity/locale/resolved hourCycle).
4. `.Time` parts: `date-picker-time.svelte` (one row) + `date-range-picker-time.svelte`
(two rows, start/end); register in each `index.ts` + `types.ts`.
5. langs: `picker-shell.ts` keys `time` / `start` / `end`.
6. Demos: date-picker uses `<DatePicker.Time />` (drop the local bridge/CSS);
date-range-picker gains a granularity control + `<DateRangePicker.Time />`.
Verify: `npx svelte-check … --threshold error` (0 new; the only pre-existing
time-picker error is `time-picker-content.svelte` size/Popover) ·
`npx vitest run src/uix/soma/components/{date-picker,date-range-picker,time-picker}` ·
browser DOM checks (preview 5180; `:hover`/screenshot tooling is flaky).
## Done this session (in the commit)
- **Calendar-nav unification** — shared `CalendarNav` context so
Calendar/RangeCalendar/DateRangePicker reuse the refined month/year selects +
prev/next buttons (deleted the range pickers' bespoke selects). New
`calendar-nav-button`, `calendar-select.css`, `visual-context`, soma `calendar-nav`.
- **Popover** — fixed trigger re-click bounce.
- **TimePicker → datetime building blocks**: `DayPeriodToggle` now composes the
system `<ToggleGroup>` (was native radios; `type=single` `deselectable=false`
solid/primary, right-aligned); sliders self-hide per granularity; `ruler` →
`showScales`; slider labels follow the canonical label scale per size;
active-toggle hover fixed (no wash; `archetypes.css` hover rule now excludes
`data-state=on` so a pressed toggle keeps its accent instead of going
content-primary/black in light).
- **DatePicker demo** — locale-derived hour cycle + composed TimePicker time row
(this is what the next-task plan generalises to the component).
- **Perf (audit A31)** — lifted selection/checked sets across 9 providers.
- **langs** — picker-shell footer strings.
## Do NOT commit (active / separate tracks)
`soma/components/words/**`, `eidos/components/palabras/**` (firm rule), the
untracked `chronos/` component, `web/routes/demos/**`, and the loose untracked
docs (`README.md`, `SYSTEM-AUDIT-*`, `Disenando_*`, `COMPARATIVA_SIUM_*`).
`metrics.ts` langs rode along this commit only because `langs/index.ts` imports it.

@ -154,18 +154,21 @@
/* Canonical "active option" highlight — hover, keyboard highlight
* (data-highlighted) and active (aria-selected / data-selected), EXCEPT a chosen
* value (data-state='checked'), which each component paints with its own accent.
* value (data-state='checked') or a pressed toggle (data-state='on'), which each
* paint their own accent. The neutral menu-row highlight must not clobber a
* pressed toggle's solid fill + contrast text (it flipped the active label to
* content-primary — black in light themes — on hover).
* One neutral surface so the active row reads identically across select,
* combobox, command, listbox and menus — no per-component repetition.
*/
[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']),
[data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']),
[data-archetype='item'][data-highlighted]:not([data-state='checked']),
[data-archetype='option'][data-highlighted]:not([data-state='checked']),
[data-archetype='item'][aria-selected='true']:not([data-state='checked']),
[data-archetype='option'][aria-selected='true']:not([data-state='checked']),
[data-archetype='item'][data-selected]:not([data-state='checked']),
[data-archetype='option'][data-selected]:not([data-state='checked']) {
[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='item'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
[data-archetype='option'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
[data-archetype='item'][aria-selected='true']:not([data-state='checked']):not([data-state='on']),
[data-archetype='option'][aria-selected='true']:not([data-state='checked']):not([data-state='on']),
[data-archetype='item'][data-selected]:not([data-state='checked']):not([data-state='on']),
[data-archetype='option'][data-selected]:not([data-state='checked']):not([data-state='on']) {
background-color: var(--color-surface-raised);
color: var(--color-content-primary);
}

@ -8,23 +8,30 @@
*
* No `value` is passed to the grid — a controlled value would make the grid
* fire a select on mount and slam the popover shut as it opens.
*
* Provider-agnostic: reads the shared `calendarNavContext`, so the SAME part
* drives Calendar, DatePicker, RangeCalendar and DateRangePicker.
*/
import { CalendarProvider } from '$soma/components/calendar';
import './calendar-select.css';
import { calendarNavContext } from '$soma/components/calendar';
import { ChevronDown } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import Popover from '../popover';
import MonthGrid from '../month-grid';
import { getCalendarVisual } from './visual-context';
import type { MonthValue, MonthGridProviderSnippetProps } from '$soma/components/month-grid';
const provider = CalendarProvider.require();
const nav = calendarNavContext.get();
// Inherit the host calendar/picker size so the grid matches.
const visual = getCalendarVisual();
let open = $state(false);
const placeholderYear = $derived(provider.opts.placeholder.current.year);
const placeholderMonth = $derived(provider.opts.placeholder.current.month);
const placeholderYear = $derived(nav.getPlaceholder().year);
const placeholderMonth = $derived(nav.getPlaceholder().month);
// Abbreviated month (matches the grid) so the trigger stays compact and never
// grows the header.
const monthLabel = $derived(
new Intl.DateTimeFormat(provider.effectiveLocale, { month: 'short' }).format(
new Intl.DateTimeFormat(nav.effectiveLocale, { month: 'short' }).format(
new Date(2000, placeholderMonth - 1, 1)
)
);
@ -34,7 +41,7 @@
function onValueChange(next: MonthValue | undefined): void {
if (!next) return;
provider.setMonth(next.month);
nav.setMonth(next.month);
open = false;
}
</script>
@ -42,7 +49,7 @@
<Popover bind:open modal={false}>
<Popover.Trigger data-calendar-month-select>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button {...props} variant="ghost" size="sm">
<Button {...props} variant="ghost" size={visual?.getSize() ?? 'md'}>
<span data-calendar-select-label>{monthLabel}</span>
<ChevronDown data-calendar-select-chevron aria-hidden="true" />
</Button>
@ -50,7 +57,13 @@
</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-calendar-select-popover>
<MonthGrid {placeholder} {value} locale={provider.effectiveLocale} {onValueChange}>
<MonthGrid
size={visual?.getSize() ?? 'md'}
{placeholder}
{value}
locale={nav.effectiveLocale}
{onValueChange}
>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Grid>
{#each months as month (month)}

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Shared visual for a calendar header navigation button (prev / next). Composes
* the system `<IconButton>` with a back / forward chevron, so EVERY
* calendar-flavoured component's prev/next renders the same chrome — each part
* only wires its own soma button (for the morfo/sema behaviour) and hands the
* `child` props through here.
*/
import type { Snippet } from 'svelte';
import { IconButton } from '$uix/eidos/components/icon-button';
import { ChevronLeft, ChevronRight } from '$uix/eidos/components/icon';
let {
props,
label,
direction,
children
}: {
props: Record<string, unknown>;
label: string;
direction: 'prev' | 'next';
children?: Snippet;
} = $props();
</script>
<IconButton {...props} variant="ghost" size="sm" aria-label={label}>
{#if children}
{@render children()}
{:else if direction === 'prev'}
<ChevronLeft />
{:else}
<ChevronRight />
{/if}
</IconButton>

@ -7,8 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
import { IconButton } from '$uix/eidos/components/icon-button';
import { ChevronRight } from '$uix/eidos/components/icon';
import CalendarNavButton from './calendar-nav-button.svelte';
import type { CalendarNextButtonProps } from './types';
let {
@ -23,8 +22,6 @@
<Calendar.NextButton {...rest}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<IconButton {...props} variant="ghost" size="sm" aria-label={label}>
{#if bodyContent}{@render bodyContent()}{:else}<ChevronRight />{/if}
</IconButton>
<CalendarNavButton {props} {label} direction="next" children={bodyContent} />
{/snippet}
</Calendar.NextButton>

@ -7,8 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
import { IconButton } from '$uix/eidos/components/icon-button';
import { ChevronLeft } from '$uix/eidos/components/icon';
import CalendarNavButton from './calendar-nav-button.svelte';
import type { CalendarPrevButtonProps } from './types';
let {
@ -23,8 +22,6 @@
<Calendar.PrevButton {...rest}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<IconButton {...props} variant="ghost" size="sm" aria-label={label}>
{#if bodyContent}{@render bodyContent()}{:else}<ChevronLeft />{/if}
</IconButton>
<CalendarNavButton {props} {label} direction="prev" children={bodyContent} />
{/snippet}
</Calendar.PrevButton>

@ -0,0 +1,45 @@
/* Header month / year SELECTORS — shared chrome for every calendar-flavoured
component (Calendar, DatePicker, RangeCalendar, DateRangePicker). Imported by
the selector components themselves so the styling travels with them instead of
living on the [data-calendar] host (which range/date-range never render). */
/* The trigger Button's own font scale is coarser than the calendar's (its xs is
14px while the calendar's is 12px), which left the button box sized for a
bigger font than the label showed (the size incoherence). Pin the Button font
to the calendar font so the box, label and chevron read as ONE size. */
[data-calendar-month-select][data-button],
[data-calendar-year-select][data-button] {
font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
}
[data-calendar-select-label] {
/* Bump the trigger label to the host's type size (the Button's own sm font is
smaller). Falls back to the md token outside a [data-calendar] host so the
shared selector still reads correctly in range / date-range. */
font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
white-space: nowrap;
}
[data-calendar-select-chevron] {
flex: 0 0 auto;
inline-size: var(--_calendar-font-size, var(--calendar-font-size-md));
block-size: var(--_calendar-font-size, var(--calendar-font-size-md));
opacity: 0.6;
}
/* The grid sits inside the popover's own surface — flatten its standalone
surface (border / bg / shadow / outer padding) so the panel isn't doubled. */
[data-calendar-select-popover] :is([data-month-grid], [data-year-grid]) {
border: none;
background: transparent;
box-shadow: none;
padding: 0;
}
/* Spanish (and other locales') Intl month abbreviations come back lowercase
("ene", "feb"); capitalize so the month reads the same regardless of locale —
matching the English "Jan" / "Feb". */
[data-calendar-month-select],
[data-calendar-select-popover] [data-month-grid-cell] {
text-transform: capitalize;
}

@ -9,26 +9,33 @@
* time the popover opens) instead of syncing back into the calendar — that
* decoupling avoids a placeholder feedback loop. No `value` is passed so the
* grid never fires a select on mount and slams the popover shut.
*
* Provider-agnostic: reads the shared `calendarNavContext`, so the SAME part
* drives Calendar, DatePicker, RangeCalendar and DateRangePicker.
*/
import { CalendarProvider } from '$soma/components/calendar';
import './calendar-select.css';
import { calendarNavContext } from '$soma/components/calendar';
import { ChevronDown, ChevronLeft, ChevronRight } from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import Popover from '../popover';
import YearGrid from '../year-grid';
import { getCalendarVisual } from './visual-context';
import type { YearValue, YearPlaceholder, YearGridProviderSnippetProps } from '$soma/components/year-grid';
const provider = CalendarProvider.require();
const nav = calendarNavContext.get();
// Inherit the host calendar/picker size so the grid matches.
const visual = getCalendarVisual();
let open = $state(false);
const selectedYear = $derived(provider.opts.placeholder.current.year);
const selectedYear = $derived(nav.getPlaceholder().year);
// Local decade browsing — re-seeded from the calendar on each open so the
// page starts on the selected year.
let navYear = $state(provider.opts.placeholder.current.year);
let navYear = $state(nav.getPlaceholder().year);
$effect(() => {
if (open) navYear = provider.opts.placeholder.current.year;
if (open) navYear = nav.getPlaceholder().year;
});
const yearLabel = $derived(provider.formatYear(selectedYear));
const yearLabel = $derived(nav.formatYear(selectedYear));
const placeholder = $derived<YearPlaceholder>({ year: navYear });
// Highlight the selected year with the SELECTED (filled) style.
const value = $derived<YearValue>({ year: selectedYear });
@ -38,7 +45,7 @@
}
function onValueChange(next: YearValue | undefined): void {
if (!next) return;
provider.setYear(next.year);
nav.setYear(next.year);
open = false;
}
</script>
@ -46,7 +53,7 @@
<Popover bind:open modal={false}>
<Popover.Trigger data-calendar-year-select>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button {...props} variant="ghost" size="sm">
<Button {...props} variant="ghost" size={visual?.getSize() ?? 'md'}>
<span data-calendar-select-label>{yearLabel}</span>
<ChevronDown data-calendar-select-chevron aria-hidden="true" />
</Button>
@ -55,10 +62,11 @@
<Popover.Portal>
<Popover.Content data-calendar-select-popover>
<YearGrid
size={visual?.getSize() ?? 'md'}
{placeholder}
{value}
pageAligned={false}
locale={provider.effectiveLocale}
locale={nav.effectiveLocale}
{onPlaceholderChange}
{onValueChange}
>

@ -162,41 +162,10 @@
outline-offset: 0;
}
/* The month / year trigger is now the system <Button> (composed via the
Popover.Trigger `child` snippet). Its box / border / bg / hover / focus /
disabled all come from the primitive — only the label font + chevron remain. */
[data-calendar-select-label] {
/* The Button's sm size is 14px; bump the label to the picker font (16px) so
the month/year text matches the calendar. Set on the label (not the Button
token) so it wins over the inherited Button font without a specificity war. */
font-size: var(--_calendar-font-size);
white-space: nowrap;
}
[data-calendar-select-chevron] {
flex: 0 0 auto;
inline-size: var(--_calendar-font-size);
block-size: var(--_calendar-font-size);
opacity: 0.6;
}
/* The grid sits inside the popover's own surface — flatten its standalone
surface (border / bg / shadow / outer padding) so the panel isn't doubled. */
[data-calendar-select-popover] :is([data-month-grid], [data-year-grid]) {
border: none;
background: transparent;
box-shadow: none;
padding: 0;
}
/* Spanish (and other locales') Intl month abbreviations come back lowercase
("ene", "feb"); capitalize so the month reads the same regardless of locale —
matching the English "Jan" / "Feb". */
[data-calendar-month-select],
[data-calendar-select-popover] [data-month-grid-cell] {
text-transform: capitalize;
}
/* Month / year selector chrome (label font, chevron, popover flatten, month
capitalization) moved to `calendar-select.css`, imported by the selector
components themselves — so RangeCalendar / DateRangePicker, which never render
a [data-calendar] host, still get the styling when they reuse the selectors. */
[data-calendar-grid] {
inline-size: max-content;

@ -2,6 +2,7 @@
import './calendar.css';
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
import { setCalendarVisual } from './visual-context';
import type { CalendarProps, CalendarProviderSnippetProps } from './types';
let {
@ -16,6 +17,8 @@
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// Month/year select popovers (MonthGrid/YearGrid) inherit this size.
setCalendarVisual({ getSize: () => resolvedSize });
</script>
<Calendar.Provider

@ -0,0 +1,24 @@
import { getContext, setContext } from 'svelte';
import type { CalendarSize } from './types';
/**
* Shared visual context for the calendar's overlay choosers. The month / year
* select popovers reuse `MonthGrid` / `YearGrid`, which must render at the SAME
* size as the calendar (picker) that hosts them. The calendar root — and the
* date-picker's calendar — publish their resolved size here so those grids
* inherit it instead of defaulting to `md`.
*/
const CALENDAR_VISUAL = Symbol('calendar-visual');
export interface CalendarVisual {
getSize(): CalendarSize;
}
export function setCalendarVisual(ctx: CalendarVisual): CalendarVisual {
setContext(CALENDAR_VISUAL, ctx);
return ctx;
}
export function getCalendarVisual(): CalendarVisual | undefined {
return getContext<CalendarVisual | undefined>(CALENDAR_VISUAL);
}

@ -73,38 +73,16 @@
margin-inline-end: auto;
}
/* The copy control composes <Clipboard> (Trigger + Indicator). The Trigger's
chrome comes from button.css; this only lays out the trigger + the transient
"Copied" indicator and sizes the indicator text. */
[data-code-block-copy] {
appearance: none;
font: inherit;
font-family: var(--font-family-mono);
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex: 0 0 auto;
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
background: transparent;
border: var(--border-width) solid var(--color-neutral-border);
border-radius: var(--radius-sm);
padding-inline: var(--space-2);
padding-block: 2px;
cursor: pointer;
white-space: nowrap;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease,
color var(--duration-fast) ease;
}
[data-code-block-copy]:hover {
background: var(--color-neutral-hover);
color: var(--color-content-primary);
}
[data-code-block-copy]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-code-block-copy][data-copied] {
color: var(--color-affirm-text);
border-color: var(--color-affirm-border);
}
[data-code-block-pre] {

@ -7,13 +7,14 @@
* {`const x = 1;\nconsole.log(x);`}
* </CodeBlock>
*
* Renders a `<div data-code-block>` shell with an optional header
* (title + language badge + copy button) and a `<pre><code>` inner
* block. The copy button is a leaf utility — no Sema events.
* Renders a `<div data-code-block>` shell with an optional header (title +
* language badge + copy control) and a `<pre><code>` inner block. The copy
* control composes the canonical `<Clipboard>` (Trigger + Indicator) — no
* bespoke copy button or clipboard logic, and the accessible "copy" label
* comes from the clipboard component's own langs.
*/
import { onDestroy } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import type { TimerHandle } from '$libs/timer';
import { Clipboard } from '$uix/eidos/components/clipboard';
import type { CodeBlockProps } from './types';
let {
@ -31,8 +32,9 @@
const eidos = ActiveEidos.require();
let codeRef = $state<HTMLElement | null>(null);
let copied = $state(false);
let copyTimer: TimerHandle | undefined;
// The copied text is the rendered code — reactive once the <code> ref binds
// on mount. (Static code blocks: settles once; that's the common case.)
const codeText = $derived(codeRef?.textContent ?? '');
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'surface');
const resolvedSize = $derived(eidos.resolve(size));
@ -53,24 +55,6 @@
});
const hasHeader = $derived(Boolean(title) || Boolean(language) || showCopy);
async function handleCopy() {
if (typeof navigator === 'undefined' || !codeRef) return;
const text = codeRef.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
copied = true;
copyTimer?.cancel();
copyTimer = eidos.timers.schedule(null, 1800, () => {
copied = false;
});
} catch {
// Clipboard unavailable — keep silent. The button still fires
// onclick so the consumer can wire a fallback if needed.
}
}
onDestroy(() => copyTimer?.cancel());
</script>
<div
@ -89,15 +73,10 @@
<span data-code-block-lang>{language}</span>
{/if}
{#if showCopy}
<button
type="button"
data-code-block-copy
data-copied={copied ? '' : undefined}
aria-label="Copy code to clipboard"
onclick={handleCopy}
>
{copied ? 'Copied' : 'Copy'}
</button>
<Clipboard value={codeText} data-code-block-copy>
<Clipboard.Trigger variant="ghost" size="xs" />
<Clipboard.Indicator />
</Clipboard>
{/if}
</div>
{/if}

@ -2,6 +2,7 @@
import { ActiveEidos } from '$uix/eidos';
import * as DatePicker from '$soma/components/date-picker';
import { getDatePickerVisualContext } from './context.svelte';
import { setCalendarVisual } from '../calendar/visual-context';
import type { DatePickerCalendarProps, DatePickerCalendarSnippetProps } from './types';
let { size, variant, color, children: bodyContent, ...rest }: DatePickerCalendarProps = $props();
@ -11,6 +12,8 @@
const resolvedSize = $derived(eidos.resolve(size ?? visual?.getSize() ?? 'md', 'md'));
const resolvedVariant = $derived(variant ?? visual?.getVariant() ?? 'ghost');
const resolvedColor = $derived(color ?? visual?.getColor() ?? 'primary');
// The month/year select popovers (MonthGrid/YearGrid) inherit the picker size.
setCalendarVisual({ getSize: () => resolvedSize });
</script>
<DatePicker.Calendar

@ -143,3 +143,25 @@
inline-size: max-content;
min-inline-size: max-content;
}
/* Expose the size-scaled calendar font on the popover content (it carries the
picker size as data-picker-size). The calendar sets --_calendar-font-size on
itself only; siblings of the calendar in the popover body — e.g. a composed
"Time" row — read it from here so they scale with the picker. */
[data-popover-content][data-picker-size='xs'] {
--_calendar-font-size: var(--calendar-font-size-xs);
}
[data-popover-content][data-picker-size='sm'] {
--_calendar-font-size: var(--calendar-font-size-sm);
}
[data-popover-content][data-picker-size='md'] {
--_calendar-font-size: var(--calendar-font-size-md);
}
[data-popover-content][data-picker-size='lg'] {
--_calendar-font-size: var(--calendar-font-size-lg);
}
/* Stable popover height across 5- and 6-row months is handled by `fixedWeeks`
on the date-picker calendar (always renders 6 weeks), not a brittle height
calc — the header height depends on the month/year buttons, so a min-block-size
estimate undershoots. See the demo's DatePicker `fixedWeeks`. */

@ -1,19 +0,0 @@
<script lang="ts">
import * as DateRangePicker from '$soma/components/date-range-picker';
import type {
DateRangePickerMonthSelectProps,
DateRangePickerMonthSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: DateRangePickerMonthSelectProps = $props();
</script>
{#if bodyContent}
<DateRangePicker.MonthSelect {...rest}>
{#snippet children(snippetProps: DateRangePickerMonthSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</DateRangePicker.MonthSelect>
{:else}
<DateRangePicker.MonthSelect {...rest} />
{/if}

@ -1,10 +1,26 @@
<script lang="ts">
/**
* Eidos `DateRangePicker.NextButton` — wires soma's next button to the SHARED
* `<CalendarNavButton>` (system IconButton + forward chevron), the same chrome
* Calendar uses, instead of a bespoke native button.
*/
import { ActiveEidos } from '$uix/eidos';
import * as DateRangePicker from '$soma/components/date-range-picker';
import CalendarNavButton from '../calendar/calendar-nav-button.svelte';
import type { DateRangePickerNextButtonProps } from './types';
let { children, ...rest }: DateRangePickerNextButtonProps = $props();
let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: DateRangePickerNextButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.next-month|Next month'));
</script>
<DateRangePicker.NextButton {...rest}>
{@render children?.()}
{#snippet child({ props }: { props: Record<string, unknown> })}
<CalendarNavButton {props} {label} direction="next" children={bodyContent} />
{/snippet}
</DateRangePicker.NextButton>

@ -1,10 +1,26 @@
<script lang="ts">
/**
* Eidos `DateRangePicker.PrevButton` — wires soma's prev button to the SHARED
* `<CalendarNavButton>` (system IconButton + back chevron), the same chrome
* Calendar uses, instead of a bespoke native button.
*/
import { ActiveEidos } from '$uix/eidos';
import * as DateRangePicker from '$soma/components/date-range-picker';
import CalendarNavButton from '../calendar/calendar-nav-button.svelte';
import type { DateRangePickerPrevButtonProps } from './types';
let { children, ...rest }: DateRangePickerPrevButtonProps = $props();
let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: DateRangePickerPrevButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.prev-month|Previous month'));
</script>
<DateRangePicker.PrevButton {...rest}>
{@render children?.()}
{#snippet child({ props }: { props: Record<string, unknown> })}
<CalendarNavButton {props} {label} direction="prev" children={bodyContent} />
{/snippet}
</DateRangePicker.PrevButton>

@ -1,19 +0,0 @@
<script lang="ts">
import * as DateRangePicker from '$soma/components/date-range-picker';
import type {
DateRangePickerYearSelectProps,
DateRangePickerYearSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: DateRangePickerYearSelectProps = $props();
</script>
{#if bodyContent}
<DateRangePicker.YearSelect {...rest}>
{#snippet children(snippetProps: DateRangePickerYearSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</DateRangePicker.YearSelect>
{:else}
<DateRangePicker.YearSelect {...rest} />
{/if}

@ -19,8 +19,11 @@ import Header from './date-range-picker-header.svelte';
import Heading from './date-range-picker-heading.svelte';
import PrevButton from './date-range-picker-prev-button.svelte';
import NextButton from './date-range-picker-next-button.svelte';
import MonthSelect from './date-range-picker-month-select.svelte';
import YearSelect from './date-range-picker-year-select.svelte';
// Month/year selectors are SHARED with Calendar — the refined popover+grid parts
// read the provider-agnostic `calendarNavContext` (RangeCalendarProvider, the
// active calendar context here, publishes it), instead of native selects.
import MonthSelect from '../calendar/calendar-month-select.svelte';
import YearSelect from '../calendar/calendar-year-select.svelte';
import Grid from './date-range-picker-grid.svelte';
import GridHead from './date-range-picker-grid-head.svelte';
import GridBody from './date-range-picker-grid-body.svelte';

@ -28,8 +28,11 @@ import Header from './range-calendar-header.svelte';
import Heading from './range-calendar-heading.svelte';
import PrevButton from './range-calendar-prev-button.svelte';
import NextButton from './range-calendar-next-button.svelte';
import MonthSelect from './range-calendar-month-select.svelte';
import YearSelect from './range-calendar-year-select.svelte';
// Month/year selectors are SHARED with Calendar — the refined popover+grid parts
// read the provider-agnostic `calendarNavContext`, which RangeCalendarProvider
// publishes, so RangeCalendar reuses them instead of its own native selects.
import MonthSelect from '../calendar/calendar-month-select.svelte';
import YearSelect from '../calendar/calendar-year-select.svelte';
import Grid from './range-calendar-grid.svelte';
import GridHead from './range-calendar-grid-head.svelte';
import GridBody from './range-calendar-grid-body.svelte';

@ -1,19 +0,0 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type {
RangeCalendarMonthSelectProps,
RangeCalendarMonthSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: RangeCalendarMonthSelectProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.MonthSelect {...rest}>
{#snippet children(snippetProps: RangeCalendarMonthSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.MonthSelect>
{:else}
<RangeCalendar.MonthSelect {...rest} />
{/if}

@ -1,10 +1,26 @@
<script lang="ts">
/**
* Eidos `RangeCalendar.NextButton` — wires soma's next button to the SHARED
* `<CalendarNavButton>` (system <IconButton> + forward chevron) — the same
* chrome Calendar uses, instead of a bespoke native button.
*/
import { ActiveEidos } from '$uix/eidos';
import * as RangeCalendar from '$soma/components/range-calendar';
import CalendarNavButton from '../calendar/calendar-nav-button.svelte';
import type { RangeCalendarNextButtonProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarNextButtonProps = $props();
let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: RangeCalendarNextButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.next-month|Next month'));
</script>
<RangeCalendar.NextButton {...rest}>
{@render bodyContent?.()}
{#snippet child({ props }: { props: Record<string, unknown> })}
<CalendarNavButton {props} {label} direction="next" children={bodyContent} />
{/snippet}
</RangeCalendar.NextButton>

@ -1,10 +1,26 @@
<script lang="ts">
/**
* Eidos `RangeCalendar.PrevButton` — wires soma's prev button to the SHARED
* `<CalendarNavButton>` (system <IconButton> + back chevron) — the same chrome
* Calendar uses, instead of a bespoke native button.
*/
import { ActiveEidos } from '$uix/eidos';
import * as RangeCalendar from '$soma/components/range-calendar';
import CalendarNavButton from '../calendar/calendar-nav-button.svelte';
import type { RangeCalendarPrevButtonProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarPrevButtonProps = $props();
let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: RangeCalendarPrevButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.prev-month|Previous month'));
</script>
<RangeCalendar.PrevButton {...rest}>
{@render bodyContent?.()}
{#snippet child({ props }: { props: Record<string, unknown> })}
<CalendarNavButton {props} {label} direction="prev" children={bodyContent} />
{/snippet}
</RangeCalendar.PrevButton>

@ -1,19 +0,0 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type {
RangeCalendarYearSelectProps,
RangeCalendarYearSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: RangeCalendarYearSelectProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.YearSelect {...rest}>
{#snippet children(snippetProps: RangeCalendarYearSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.YearSelect>
{:else}
<RangeCalendar.YearSelect {...rest} />
{/if}

@ -192,51 +192,16 @@
text-align: center;
}
[data-range-calendar-prev-button],
[data-range-calendar-next-button] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_calendar-control-size);
block-size: var(--_calendar-control-size);
padding: 0;
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
cursor: pointer;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-prev-button]:hover,
[data-range-calendar-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
/* Prev / next compose the shared <CalendarNavButton> (system IconButton); the
month / year selectors are the shared refined popovers (their chrome lives in
calendar-select.css). Box / hover / focus / disabled all come from those
primitives — no bespoke chrome here. */
[data-range-calendar-prev-button]:focus-visible,
[data-range-calendar-next-button]:focus-visible,
[data-range-calendar-month-select]:focus-visible,
[data-range-calendar-year-select]:focus-visible,
[data-range-calendar-day]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-range-calendar-prev-button][data-disabled],
[data-range-calendar-next-button][data-disabled],
[data-range-calendar-month-select][data-disabled],
[data-range-calendar-year-select][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar-month-select],
[data-range-calendar-year-select] {
min-inline-size: 0;

@ -7,6 +7,7 @@
*/
import { ActiveEidos } from '$uix/eidos';
import * as RangeCalendar from '$soma/components/range-calendar';
import { setCalendarVisual } from '../calendar/visual-context';
import type { RangeCalendarProps, RangeCalendarProviderSnippetProps } from './types';
let {
@ -21,6 +22,8 @@
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// The shared month/year select popovers (MonthGrid/YearGrid) inherit this size.
setCalendarVisual({ getSize: () => resolvedSize });
</script>
<RangeCalendar.Provider

@ -8,12 +8,19 @@ export interface TimePickerVisualContext {
getVariant(): TimePickerVariant;
getColor(): TimePickerColor;
/**
* Whether to render the labelled ruler scale next to each slider.
* `true` by default; pass `ruler={false}` on `<TimePicker>` to hide
* the rulers entirely, or pass `ruler={false}` on an individual
* slider eidos wrapper to opt that one out.
* Whether to render the labelled scale (ruler) next to each slider,
* showing the hour / minute / second range. `true` by default; pass
* `showScales={false}` on `<TimePicker>` to hide every scale, or
* `showScales={false}` on an individual slider wrapper to opt that one out.
*/
getRuler(): boolean;
getShowScales(): boolean;
/**
* Resolved finest time channel. Sliders finer than this self-hide, so a
* consumer can compose Hour/Minute/Second unconditionally and the clock
* honours the picker's `granularity` automatically (e.g. `'hour'` shows
* only the hour slider).
*/
getGranularity(): 'hour' | 'minute' | 'second';
}
export function setTimePickerVisualContext(ctx: TimePickerVisualContext): TimePickerVisualContext {

@ -1,17 +1,45 @@
<script lang="ts">
/**
* Eidos `<TimePicker.DayPeriodToggle>` — composes the system
* `<ToggleGroup>` (single-select, segmented) wired to the picker's AM/PM
* state, instead of soma's bespoke native radios. The soma part exposes the
* value / setter / items via its `asChild` snippet; this wrapper paints them
* with the framework component so the toggle matches the rest of the system.
*/
import * as TimePicker from '$soma/components/time-picker';
import type { Snippet } from 'svelte';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { getTimePickerVisualContext } from './context.svelte';
type Props = {
id?: string;
children?: Snippet;
class?: string;
style?: string;
};
let { children, ...rest }: Props = $props();
let { ...rest }: Props = $props();
const visual = getTimePickerVisualContext();
</script>
<TimePicker.DayPeriodToggle {...rest}>
{@render children?.()}
{#snippet child({ props, value, setValue, disabled, items })}
<ToggleGroup
{...props}
type="single"
deselectable={false}
value={[value]}
onValueChange={(next) => {
if (next[0]) setValue(next[0] as 'AM' | 'PM');
}}
{disabled}
attached
variant="solid"
color="primary"
size={visual?.getSize() ?? 'md'}
>
{#each items as item (item.value)}
<ToggleGroup.Item value={item.value}>{item.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
{/snippet}
</TimePicker.DayPeriodToggle>

@ -13,16 +13,16 @@
import type { TimePickerSliderProps } from './types';
type Props = TimePickerSliderProps & {
/** Show the labelled ruler above the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
/** Show the labelled scale above the track. Falls back to the
* picker-level `showScales` flag (default `true`). */
showScales?: boolean;
};
let { children, ruler, ...rest }: Props = $props();
let { children, showScales, ...rest }: Props = $props();
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const scalesVisible = $derived(showScales ?? visual?.getShowScales() ?? true);
// Channel label — visual caption naming the slider. The soma Slider
// already carries the same text as its `aria-label`, so the visual
// caption is `aria-hidden`. Shared `common.time.*` langref.
@ -31,7 +31,7 @@
<div data-time-picker-hour-slider class="time-picker-slider time-picker-hour-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
{#if showRuler}
{#if scalesVisible}
<TimePicker.HourScale class="time-picker-scale">
{#snippet children({ ticks, percent, label })}
{#each ticks as v}

@ -6,12 +6,12 @@
import type { TimePickerSliderProps } from './types';
type Props = TimePickerSliderProps & {
/** Show the labelled ruler below the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
/** Show the labelled scale below the track. Falls back to the
* picker-level `showScales` flag (default `true`). */
showScales?: boolean;
};
let { children, ruler, ...rest }: Props = $props();
let { children, showScales, ...rest }: Props = $props();
// Endpoint-inclusive: 0, 15, 30, 45, 59. Final mark sits at the right
// end so the user sees the upper bound of the available range.
@ -21,7 +21,9 @@
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const scalesVisible = $derived(showScales ?? visual?.getShowScales() ?? true);
// Self-hide when the picker granularity is coarser than minutes (e.g. 'hour').
const granularity = $derived(visual?.getGranularity() ?? 'minute');
const label = $derived(eidos.langs.ts('#?common.time.minute|Minute'));
function percent(v: number): string {
@ -30,24 +32,26 @@
}
</script>
<div data-time-picker-minute-slider class="time-picker-slider time-picker-minute-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.MinuteSlider {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{#if granularity !== 'hour'}
<div data-time-picker-minute-slider class="time-picker-slider time-picker-minute-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.MinuteSlider {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{/if}
</TimePicker.MinuteSlider>
{#if scalesVisible}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</TimePicker.MinuteSlider>
{#if showRuler}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</div>
</div>
{/if}

@ -6,12 +6,12 @@
import type { TimePickerSliderProps } from './types';
type Props = TimePickerSliderProps & {
/** Show the labelled ruler below the track. Falls back to the
* picker-level `ruler` flag (default `true`). */
ruler?: boolean;
/** Show the labelled scale below the track. Falls back to the
* picker-level `showScales` flag (default `true`). */
showScales?: boolean;
};
let { children, ruler, ...rest }: Props = $props();
let { children, showScales, ...rest }: Props = $props();
const tickValues = [0, 15, 30, 45, 59];
const min = 0;
@ -19,7 +19,9 @@
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const scalesVisible = $derived(showScales ?? visual?.getShowScales() ?? true);
// Only shown when the picker granularity reaches seconds.
const granularity = $derived(visual?.getGranularity() ?? 'minute');
const label = $derived(eidos.langs.ts('#?common.time.second|Second'));
function percent(v: number): string {
@ -28,24 +30,26 @@
}
</script>
<div data-time-picker-second-slider class="time-picker-slider time-picker-second-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.SecondSlider {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{#if granularity === 'second'}
<div data-time-picker-second-slider class="time-picker-slider time-picker-second-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.SecondSlider {...rest}>
{#if children}
{@render children?.()}
{:else}
<Slider.Range />
<Slider.Thumb />
{/if}
</TimePicker.SecondSlider>
{#if scalesVisible}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</TimePicker.SecondSlider>
{#if showRuler}
<div class="time-picker-scale time-picker-scale-below" aria-hidden="true">
{#each tickValues as v}
<span class="time-picker-scale-tick" style:left={percent(v)}>
<span class="time-picker-scale-mark"></span>
<span class="time-picker-scale-label">{String(v).padStart(2, '0')}</span>
</span>
{/each}
</div>
{/if}
</div>
</div>
{/if}

@ -175,12 +175,44 @@
font-family: var(--font-ui);
font-size: var(--font-size-sm);
line-height: var(--leading-ui);
/* Slider channel caption — canonical label scale (one step below the
control), keyed off the clock size so it adapts like every labelled
control. Values mirror recipes/base.ts label-font-size-{xs,sm,md,lg}. */
--_time-picker-label-font: var(--font-size-sm);
}
[data-time-picker-clock][data-size='xs'] {
--_time-picker-label-font: var(--font-size-xxs);
}
[data-time-picker-clock][data-size='sm'] {
--_time-picker-label-font: var(--font-size-xs);
}
[data-time-picker-clock][data-size='lg'] {
--_time-picker-label-font: var(--font-size-md);
}
[data-time-picker-clock] [data-slider] {
inline-size: 100%;
}
/* Day-period (AM/PM) toggle aligns to the inline end of the clock column. */
[data-time-picker-clock] [data-time-picker-day-period-toggle] {
align-self: flex-end;
}
/* The selected item is already a solid fill; Toggle's default solid-on-hover
step lightens + desaturates it (washing the fill toward white). A selected,
non-deselectable radio-like item needs no hover affordance — pin its hover
to the resting solid fill so it stays put. */
[data-time-picker-clock]
[data-time-picker-day-period-toggle]
[data-toggle-group-item][data-state='on']:hover {
background: var(--toggle-palette-solid);
border-color: var(--toggle-palette-solid);
}
/* ── Labelled scale (ruler) above hour slider, below minute/second ───────── */
.time-picker-slider {
@ -197,7 +229,7 @@
margin-block-end: var(--space-1);
color: var(--color-content-secondary);
font-family: var(--font-ui);
font-size: var(--font-size-xxs);
font-size: var(--_time-picker-label-font, var(--font-size-xxs));
font-weight: var(--font-weight-medium);
letter-spacing: var(--tracking-caps);
line-height: 1;

@ -15,7 +15,8 @@
size = 'md',
variant = 'surface',
color = 'primary',
ruler = true,
showScales = true,
granularity,
value = $bindable(),
placeholder = $bindable(),
open = $bindable(false),
@ -30,7 +31,8 @@
getSize: () => resolvedSize,
getVariant: () => variant,
getColor: () => color,
getRuler: () => ruler
getShowScales: () => showScales,
getGranularity: () => granularity ?? 'minute'
});
// Footer controls follow the picker size.
setPickerShellVisual({ getSize: () => resolvedSize });
@ -38,6 +40,7 @@
<TimePicker.Provider
{...rest}
{granularity}
bind:value
bind:placeholder
bind:open

@ -21,7 +21,7 @@ import type {
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
import type { Snippet } from 'svelte'
export type TimePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>
export type TimePickerSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg'>
export type TimePickerVariant = ControlVariant
export type TimePickerColor = ColorRole
@ -33,10 +33,10 @@ export type TimePickerProps = ProviderProps & {
/** Intent palette used for focus and selected affordances. @default 'primary' */
color?: TimePickerColor
/**
* Show the labelled ruler scale next to each slider in the clock
* popover. @default true
* Show the labelled scale (ruler) next to each slider in the clock
* popover, marking the hour / minute / second range. @default true
*/
ruler?: boolean
showScales?: boolean
}
export type TimePickerTriggerProps = SomaTimePickerTriggerProps & {

@ -9,6 +9,7 @@ import { buttonLangs } from './button';
import { buttonGroupLangs } from './button-group';
import { calendarLangs } from './calendar';
import { carouselLangs } from './carousel';
import { chartLangs } from './chart';
import { checkboxLangs } from './checkbox';
import { clipboardLangs } from './clipboard';
import { collapsibleLangs } from './collapsible';
@ -42,12 +43,14 @@ import { listboxLangs } from './listbox';
import { menubarLangs } from './menubar';
import { meterLangs } from './meter';
import { menuDialLangs } from './menu-dial';
import { metricsLangs } from './metrics';
import { monthGridLangs } from './month-grid';
import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field';
import { onionMenuLangs } from './onion-menu';
import { paginationLangs } from './pagination';
import { passwordFieldLangs } from './password-field';
import { pickerShellLangs } from './picker-shell';
import { pinInputLangs } from './pin-input';
import { popoverLangs } from './popover';
import { progressLangs } from './progress';
@ -102,6 +105,7 @@ export const componentLangs = {
'button-group': buttonGroupLangs,
calendar: calendarLangs,
carousel: carouselLangs,
chart: chartLangs,
checkbox: checkboxLangs,
clipboard: clipboardLangs,
collapsible: collapsibleLangs,
@ -135,12 +139,14 @@ export const componentLangs = {
menubar: menubarLangs,
'menu-dial': menuDialLangs,
meter: meterLangs,
metrics: metricsLangs,
'month-grid': monthGridLangs,
'navigation-menu': navigationMenuLangs,
'number-field': numberFieldLangs,
'onion-menu': onionMenuLangs,
pagination: paginationLangs,
'password-field': passwordFieldLangs,
'picker-shell': pickerShellLangs,
'pin-input': pinInputLangs,
popover: popoverLangs,
progress: progressLangs,

@ -0,0 +1,28 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the metrics component. Merged under `components.metrics.*`
* by `ActiveUix` (via the `componentLangs` barrel).
*
* The trend phrases give the Delta an accessible name: the arrow glyph is
* `aria-hidden`, so a screen reader reads "Increased by 12%" instead of an
* undecipherable "▲ 12%". `{{value}}` interpolates the delta's value text.
*/
export const metricsLangs = {
label: {
es: 'Métrica',
en: 'Metric'
},
'trend-up': {
es: 'Aumentó {{value}}',
en: 'Increased by {{value}}'
},
'trend-down': {
es: 'Disminuyó {{value}}',
en: 'Decreased by {{value}}'
},
'trend-flat': {
es: 'Sin cambios',
en: 'No change'
}
} satisfies LangNode;

@ -0,0 +1,24 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the picker-shell footer actions (Clear / Cancel / Done),
* shared by every composite picker (Date / Time / Color pickers). Merged under
* `components.picker-shell.*` by `ActiveUix` (via the `componentLangs` barrel).
*
* The eidos parts reference these by absolute idlangref:
* `'#?components.picker-shell.{key}|fallback'`.
*/
export const pickerShellLangs = {
clear: {
es: 'Borrar',
en: 'Clear'
},
cancel: {
es: 'Cancelar',
en: 'Cancel'
},
close: {
es: 'Listo',
en: 'Done'
}
} satisfies LangNode;

@ -0,0 +1,30 @@
import { context } from '../../provider';
import type { DateValue } from '$libs/days';
/**
* The minimal calendar-navigation surface the refined header chrome (the month
* and year selectors, prev / next buttons) consumes. BOTH `CalendarProvider`
* and `RangeCalendarProvider` implement it and publish themselves under
* `calendarNavContext`, so a SINGLE set of eidos chrome parts drives the
* Calendar, DatePicker, RangeCalendar and DateRangePicker — none re-implement
* their own selectors/buttons.
*
* It deliberately omits the day-grid surface: range calendars genuinely differ
* there (in-range / hover-preview), so the grid parts stay per component. Only
* navigation chrome — which is identical between the two providers — is shared.
*/
export interface CalendarNav {
/** Resolved locale used for month / year formatting and the grids. */
readonly effectiveLocale: string;
/** The currently displayed month (placeholder) the chrome reads + writes. */
getPlaceholder(): DateValue;
setMonth(month: number, target?: HTMLElement): void;
setYear(year: number, target?: HTMLElement): void;
formatYear(year: number): string;
}
/**
* Shared context published by every calendar-flavoured provider. Refined chrome
* reads it instead of a concrete provider, so it stays provider-agnostic.
*/
export const calendarNavContext = context<CalendarNav>('CalendarNav');

@ -6,6 +6,7 @@ import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types'
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { CALENDAR_LANGS } from './langs';
import { calendarNavContext, type CalendarNav } from './calendar-nav';
import {
type DateValue,
type DateMatcher,
@ -114,7 +115,7 @@ function sameDateArray(a: DateValue[], b: DateValue[]): boolean {
return a.every((date, index) => isSameDay(date, b[index]));
}
export class CalendarProvider {
export class CalendarProvider implements CalendarNav {
readonly opts: CalendarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -157,6 +158,11 @@ export class CalendarProvider {
props: { disabled: () => opts.disabled.current }
});
// Publish the shared nav contract so the refined chrome (month/year
// selectors, prev/next) drives this calendar without a concrete-provider
// dependency.
calendarNavContext.set(this);
// Initial focus management used to live in the wrapper. Move it here so
// the wrapper stays thin and the focus contract is centralized.
$effect(() => {
@ -355,6 +361,11 @@ export class CalendarProvider {
this.setPlaceholder(this.opts.placeholder.current.set({ year }), target);
}
/** CalendarNav: the currently displayed month the chrome reads + writes. */
getPlaceholder(): DateValue {
return this.opts.placeholder.current;
}
// ── Navigation view (day ↔ month ↔ year) ──────────────────────────────────
setView(view: CalendarView): void {

@ -18,6 +18,12 @@ export { default as Day } from './components/calendar-day.svelte';
// `advanceView` / `selectMonthView` / `selectYearView` off the live context.
export { CalendarProvider } from './calendar-provider.svelte';
// Shared navigation contract: both CalendarProvider and RangeCalendarProvider
// publish themselves here so ONE set of refined chrome (month/year selectors,
// prev/next) drives every calendar-flavoured component.
export { calendarNavContext } from './calendar-nav';
export type { CalendarNav } from './calendar-nav';
export type {
CalendarView,
CalendarProps,

@ -310,8 +310,11 @@ export class CheckboxGroupProvider {
});
}
/** Lifted checked set (A31): per-item `isItemChecked` does O(1) `.has()`, not O(N) `.includes()`. */
readonly checkedSet = $derived.by(() => new Set(this.opts.value.current));
isItemChecked(value: string): boolean {
return this.opts.value.current.includes(value);
return this.checkedSet.has(value);
}
updateItemChecked(value: string, checked: boolean) {

@ -179,8 +179,11 @@ export class ComboboxProvider {
// The input stays for filtering — we don't stamp labels into it.
}
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
/** True when both `inputValue` is empty and no values are selected. Drives `data-empty` + clear button disabled state. */

@ -147,8 +147,11 @@ export class GridListProvider {
// ── Selection ───────────────────────────────────────────────────────────
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {

@ -154,8 +154,16 @@ export class ListboxProvider {
// ── Selection ───────────────────────────────────────────────────────────
/**
* Lifted selection set — a single `$derived` so per-item `isSelected`
* derivations do an O(1) `.has()` instead of an O(N) `Array.includes()`.
* Mirrors `rovingTargetEl`: `value` invalidates one derivation, items just
* look up. Avoids the O(N²) cascade on large multi-selects (A31).
*/
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
select(value: string, mode?: 'replace' | 'toggle' | 'add') {

@ -18,6 +18,7 @@ import type {
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { POPOVER_LANGS } from './langs';
import { contains } from '$adom';
import type { TimerHandle } from '$timer';
import { Presence } from '../../layers/presence.svelte';
@ -207,6 +208,16 @@ export class PopoverProvider {
this.triggerRef.current = el;
}
/**
* The registered trigger element (or null). Content reads it to exclude the
* trigger from the outside-dismiss: a re-click on an open trigger must close
* cleanly via the trigger's own toggle — without this, the pointerdown
* outside-dismiss closes and the click immediately re-opens (bounce).
*/
get triggerNode(): HTMLElement | null {
return this.triggerRef.current;
}
setOverlayRef(el: HTMLElement | null) {
this.overlayRef.current = el;
}
@ -546,6 +557,13 @@ export class PopoverContentProvider {
}),
escapeKeydownBehavior: resolvedEscapeBehavior,
onInteractOutside: readableActive(() => (e: PointerEvent) => {
// The trigger sits outside Content, so a re-click on an open trigger
// reads as an "outside" interaction — dismissing on pointerdown, then
// the trigger's click re-opens (bounce). Let the trigger own its
// toggle: ignore an outside interaction that lands on it.
const triggerNode = this.provider.triggerNode;
const target = e.target as Node | null;
if (triggerNode && target && contains(triggerNode, target)) return;
opts.onInteractOutside.current(e);
if (!e.defaultPrevented) void this.provider.dismissWith('dismiss-outside');
}),

@ -6,6 +6,7 @@ import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types'
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { RANGE_CALENDAR_LANGS } from './langs';
import { calendarNavContext, type CalendarNav } from '../calendar/calendar-nav';
import {
type DateValue,
type DateMatcher,
@ -102,7 +103,7 @@ export function resolveInitialRangeCalendarPlaceholder(
return new CalendarDate(t.year, t.month, t.day);
}
export class RangeCalendarProvider {
export class RangeCalendarProvider implements CalendarNav {
readonly opts: RangeCalendarOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
@ -149,6 +150,10 @@ export class RangeCalendarProvider {
props: { disabled: () => this.opts.disabled.current }
});
// Publish the shared nav contract so the refined chrome (month/year
// selectors, prev/next) drives this range calendar too.
calendarNavContext.set(this);
// Initial focus management used to live in the wrapper. Centralize it
// in the provider so the wrapper stays thin.
$effect(() => {
@ -326,6 +331,11 @@ export class RangeCalendarProvider {
void this.runtime.trigger('shift-navigate');
}
/** CalendarNav: the currently displayed month the chrome reads + writes. */
getPlaceholder(): DateValue {
return this.opts.placeholder.current;
}
private announceNavigation(): void {
if (!this.opts.announceNavigation.current) return;
this.announcement = this.headingValue;

@ -155,8 +155,11 @@ export class SelectProvider {
});
}
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
private resolveItemEl(value: string): HTMLElement | null {

@ -112,8 +112,11 @@ export class TagGroupProvider {
// ── Selection / removal ────────────────────────────────────────────────
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
select(value: string, target?: HTMLElement) {

@ -3,13 +3,28 @@
import { createId } from '$active-uix/id';
import { TimePickerProvider, timePickerAttrs } from '../time-picker-provider.svelte';
import { TIME_PICKER_LANGS } from '../langs';
import type { WithChild, Without } from '../../../types';
import type { Snippet } from 'svelte';
import type { Without } from '../../../types';
import type { PrimitiveDivAttributes } from '../../../types';
type TimePickerDayPeriodToggleProps = WithChild<{
/**
* Payload handed to an `asChild` consumer so eidos can compose a system
* component (e.g. `<ToggleGroup>`) wired to the AM/PM state, instead of the
* bespoke native radios rendered by the no-child default below.
*/
type DayPeriodChildArgs = {
props: Record<string, unknown>;
value: 'AM' | 'PM';
setValue: (next: 'AM' | 'PM') => void;
disabled: boolean;
items: { value: 'AM' | 'PM'; label: string }[];
};
type TimePickerDayPeriodToggleProps = {
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
children?: Snippet;
child?: Snippet<[DayPeriodChildArgs]>;
} & Without<PrimitiveDivAttributes, {}>;
const uid = $props.id();
@ -41,11 +56,31 @@
'aria-disabled': disabled || undefined
} as Record<string, unknown>)
);
// Props for the asChild branch — no `role` (the composed system component
// owns its own role) and no `aria-disabled` (it takes a `disabled` prop).
const childProps = $derived(
mergeProps(restProps, {
[timePickerAttrs['day-period-toggle']]: '',
'aria-label': dayPeriodLabel
} as Record<string, unknown>)
);
const items = $derived([
{ value: 'AM' as const, label: amLabel },
{ value: 'PM' as const, label: pmLabel }
]);
</script>
{#if provider.resolvedHourCycle === 12}
{#if child}
{@render child({ props: mergedProps })}
{@render child({
props: childProps,
value: provider.dayPeriod,
setValue: (next) => provider.setDayPeriod(next),
disabled,
items
})}
{:else}
<div {...mergedProps}>
<label

@ -61,8 +61,11 @@ export class ToggleGroupProvider {
});
}
/** Lifted pressed set (A31): per-item `isItemPressed` does O(1) `.has()`, not O(N) `.includes()`. */
readonly pressedSet = $derived.by(() => new Set(this.opts.value.current));
isItemPressed(value: string): boolean {
return this.opts.value.current.includes(value);
return this.pressedSet.has(value);
}
toggleItem(value: string) {

@ -144,8 +144,11 @@ export class TreeGridProvider {
// ── Expand / collapse ──────────────────────────────────────────────────
/** Lifted expansion set (A31): per-row `isExpanded` does O(1) `.has()`, not O(N) `.includes()`. */
readonly expandedSet = $derived.by(() => new Set(this.opts.expanded.current));
isExpanded(value: string): boolean {
return this.opts.expanded.current.includes(value);
return this.expandedSet.has(value);
}
toggleExpand(value: string) {
@ -185,8 +188,11 @@ export class TreeGridProvider {
// ── Selection ───────────────────────────────────────────────────────────
/** Lifted selection set (A31): per-row `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.value.current));
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
return this.selectedSet.has(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {

@ -93,8 +93,11 @@ export class TreeViewProvider {
// ── Expand ───────────────────────────────────────────────────────────────
/** Lifted expansion set (A31): per-item `isExpanded` does O(1) `.has()`, not O(N) `.includes()`. */
readonly expandedSet = $derived.by(() => new Set(this.opts.expandedValue.current));
isExpanded(value: string): boolean {
return this.opts.expandedValue.current.includes(value);
return this.expandedSet.has(value);
}
toggleExpand(value: string) {
@ -142,8 +145,11 @@ export class TreeViewProvider {
// ── Select ───────────────────────────────────────────────────────────────
/** Lifted selection set (A31): per-item `isSelected` does O(1) `.has()`, not O(N) `.includes()`. */
readonly selectedSet = $derived.by(() => new Set(this.opts.selectedValue.current));
isSelected(value: string): boolean {
return this.opts.selectedValue.current.includes(value);
return this.selectedSet.has(value);
}
select(value: string, fromEl?: HTMLElement) {

@ -34,7 +34,6 @@
toTime,
type DateMatcher,
type DateValue,
type Granularity,
type TimeValue,
type WeekStartsOn
} from '$libs/days';
@ -75,13 +74,16 @@
let mode = $state<'inline' | 'modal'>('inline');
let kind = $state<'day' | 'month' | 'year'>('day');
let withTime = $state(false);
// Finest time channel shown when datetime is on. 'hour' → only the hour
// slider; 'minute' → + minute; 'second' → + second.
let timeGranularity = $state<'hour' | 'minute' | 'second'>('minute');
let timeOpen = $state(false);
// Datetime is meaningful only for the day grid. The date input stays
// date-only (granularity 'day') — the composed <TimePicker> below the
// calendar owns the time portion of the value.
const granularity = $derived<Granularity | undefined>(
withTime && kind === 'day' ? 'day' : undefined
// Datetime is meaningful only for the day grid. The DateField input itself
// INCORPORATES the granularity (renders HH:MM time segments); the composed
// <TimePicker> below the calendar is a second, visual way to set the same time.
const granularity = $derived<'hour' | 'minute' | 'second' | undefined>(
withTime && kind === 'day' ? timeGranularity : undefined
);
// Bridge the time-of-day portion of the picker value to the composed
@ -127,7 +129,9 @@
let showShellHeader = $state(false);
let disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(false);
// On by default so the popover keeps a constant 6-row height (the popup no
// longer grows between 5- and 6-week months).
let fixedWeeks = $state(true);
let pagedNavigation = $state(false);
let preventDeselect = $state(false);
let showWeekNumbers = $state(false);
@ -136,6 +140,14 @@
let numberOfMonths = $state(1);
let weekStartsOn = $state<WeekStartControl>('1');
let locale = $state('en-US');
// Derive the hour cycle from the chosen locale and pass it as an explicit
// property to the picker + the composed TimePicker. The field providers
// otherwise resolve the cycle from the app-wide `getHourCycle()` preference
// (which wins over a field's `locale` prop), so a locale change alone would
// not flip 24h ⇄ 12h. en-US → 12h (AM/PM), es-ES / de → 24h.
const localeHourCycle = $derived<12 | 24>(
new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ? 12 : 24
);
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<DatePickerSize>('md');
let variant = $state<DatePickerVariant>('surface');
@ -338,6 +350,7 @@
mode === 'modal' && ` mode="${mode}"`,
kind !== 'day' && ` kind="${kind}"`,
granularity && ` granularity="${granularity}"`,
granularity && ' hourCycle={localeHourCycle}',
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
@ -376,10 +389,13 @@
' {/snippet}',
' </DatePicker.Calendar>',
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={24}>',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={localeHourCycle} {granularity}>',
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
granularity && ' <TimePicker.HourSlider /><TimePicker.MinuteSlider />',
granularity && ' <TimePicker.HourSlider />',
granularity && ' <TimePicker.MinuteSlider /> <!-- self-hides at granularity=hour -->',
granularity && ' <TimePicker.SecondSlider /> <!-- self-hides unless granularity=second -->',
granularity && ' <TimePicker.DayPeriodToggle /> <!-- self-hides at 24h -->',
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
granularity && ' </TimePicker>',
' </PickerShell.Body>',
@ -427,6 +443,7 @@
mode === 'modal' && ` mode="${mode}"`,
kind !== 'day' && ` kind="${kind}"`,
granularity && ` granularity="${granularity}"`,
granularity && ' hourCycle={localeHourCycle}',
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
@ -465,10 +482,13 @@
' {/snippet}',
' </DatePicker.Calendar>',
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={24}>',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={localeHourCycle} {granularity}>',
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
granularity && ' <TimePicker.HourSlider /><TimePicker.MinuteSlider />',
granularity && ' <TimePicker.HourSlider />',
granularity && ' <TimePicker.MinuteSlider /> <!-- self-hides at granularity=hour -->',
granularity && ' <TimePicker.SecondSlider /> <!-- self-hides unless granularity=second -->',
granularity && ' <TimePicker.DayPeriodToggle /> <!-- self-hides at 24h -->',
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
granularity && ' </TimePicker>',
' </PickerShell.Body>',
@ -539,6 +559,7 @@
{mode}
{kind}
{granularity}
hourCycle={localeHourCycle}
minValue={minValue}
maxValue={maxValue}
weekStartsOn={resolvedWeekStartsOn}
@ -582,10 +603,12 @@
value={timeValue}
onValueChange={setTimeValue}
bind:open={timeOpen}
size="sm"
{size}
{variant}
{color}
hourCycle={24}
{locale}
hourCycle={localeHourCycle}
{granularity}
>
<TimePicker.Input aria-label="Time">
{#snippet children({ segments }: TimePickerInputSnippetProps)}
@ -598,9 +621,14 @@
<TimePicker.Portal>
<TimePicker.Content>
<PickerShell.Body>
<!-- Compose all channels; the TimePicker self-hides
the sliders / AM-PM toggle that the resolved
granularity + hour cycle don't apply. -->
<TimePicker.Clock>
<TimePicker.HourSlider />
<TimePicker.MinuteSlider />
<TimePicker.SecondSlider />
<TimePicker.DayPeriodToggle />
</TimePicker.Clock>
</PickerShell.Body>
</TimePicker.Content>
@ -791,6 +819,23 @@
/><span data-uix-switch-label>include time</span></span
>
</label>
<label data-uix-control>
<span data-uix-control-label
>granularity <span data-uix-control-hint>finest time channel + slider</span></span
>
<span data-uix-chips role="radiogroup">
{#each ['hour', 'minute', 'second'] as value (value)}
<button
data-uix-chip
data-active={timeGranularity === value}
disabled={!withTime || kind !== 'day'}
onclick={() => (timeGranularity = value as 'hour' | 'minute' | 'second')}
>
{value}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>footer <span data-uix-control-hint>compose parts inside &lt;Footer&gt;</span></span
@ -1273,16 +1318,26 @@
.date-picker-time-row {
display: flex;
align-items: center;
justify-content: space-between;
/* Centered so the TimePicker isn't shoved against the popover edge (was
space-between). Full-width row → the divider spans it; content centered. */
justify-content: center;
gap: var(--space-3);
padding-block: var(--space-2);
border-block-start: var(--border-width, 1px) solid var(--color-border-subtle);
padding-block-start: var(--space-2);
padding-inline: var(--space-2);
/* `--color-border-subtle` / `--calendar-border` resolve to the SAME oklch as
the popover surface here (invisible). Use the neutral border so the
divider actually shows. */
border-block-start: var(--border-width, 1px) solid var(--color-neutral-border);
}
.date-picker-time-label {
flex: 0 0 auto;
color: var(--color-content-secondary);
font-size: var(--font-size-sm);
/* Scales with the picker — date-picker.css now sets --_calendar-font-size on
the popover content per data-picker-size, so this simple (scoped) rule
inherits the right size. The previous [data-popover-content] demo selector
couldn't scope to the portaled popover. */
font-size: var(--_calendar-font-size);
}
/* Content-sized, no shrink: keeps the segmented input from collapsing and

@ -1056,7 +1056,7 @@
{#snippet calendarBody({ months, weekdays, validationMessage }: DateRangePickerCalendarSnippetProps)}
<DateRangePicker.Header>
<DateRangePicker.PrevButton aria-label="Previous month">&lt;</DateRangePicker.PrevButton>
<DateRangePicker.PrevButton aria-label="Previous month" />
<div class="range-header-titles" data-month-count={months.length}>
{#each months as month (`hdr-${month.value.toString()}`)}
<div class="range-month-title" id={monthHeadingId(month.value)}>
@ -1064,7 +1064,7 @@
</div>
{/each}
</div>
<DateRangePicker.NextButton aria-label="Next month">&gt;</DateRangePicker.NextButton>
<DateRangePicker.NextButton aria-label="Next month" />
</DateRangePicker.Header>
{#if validationMessage || boundsMessage || spanMessage}
@ -1184,7 +1184,10 @@
.range-month-title {
color: var(--calendar-heading-color);
font-size: var(--calendar-heading-font-size);
/* Scale with the picker size — the calendar sets --_calendar-font-size per
data-size. The fixed heading token ignored the picker size; this matches
the standalone RangeCalendar heading (which inherits the same token). */
font-size: var(--_calendar-font-size);
font-weight: var(--calendar-heading-font-weight);
text-align: center;
}

@ -26,7 +26,7 @@
let touched = $state(false);
let stateOverride = $state<FieldState>('idle');
let size = $state<FieldSize>('md');
let variant = $state<FieldVariant>('outline');
let variant = $state<FieldVariant>('surface');
let orientation = $state<FieldOrientation>('vertical');
let color = $state<FieldColor>('primary');

@ -11,7 +11,7 @@
let stageRef = $state<HTMLElement | null>(null);
// ── Live state — Grid container props ────────────────────────────────
let templateColumns = $state<string>('repeat(3, 1fr)');
let templateColumns = $state<string>('repeat(5, 1fr)');
let autoFlow = $state<'row' | 'column' | 'dense' | 'row dense' | 'column dense'>('row');
let align = $state<'stretch' | 'start' | 'center' | 'end' | 'baseline'>('stretch');
let justify = $state<
@ -19,6 +19,41 @@
>('start');
let gap = $state<number>(3);
let padding = $state<number>(4);
// `autoRows` gives implicit rows a height. Without it, rows are sized by
// content, so a row-spanning cell collapses to its own content height when
// no sibling cell shares those rows (e.g. when colSpan covers every
// column). A fixed row height makes rowSpan visible regardless of colSpan —
// the same reason Chakra's example pins `templateRows`.
let autoRows = $state<string>('4rem');
// ── Live state — item placement on the featured cell (child Box) ──────
// Grid item span lives on the child `<Box>`, not the container — the
// UIX equivalent of Chakra's `<GridItem rowSpan colSpan>`. `span N`
// maps to `grid-row` / `grid-column` on the Box.
let rowSpan = $state<number>(2);
let colSpan = $state<number>(2);
// A cell can only span as many columns as the grid actually has, so the
// colSpan ceiling tracks the live column count read off the rendered grid
// (handles any `templateColumns` the user types — repeat(), lists, …).
let columnCount = $state<number>(5);
$effect(() => {
templateColumns; // re-measure whenever the track list changes
const grid = stageRef?.querySelector('[data-grid]');
if (!grid) return;
const cols = getComputedStyle(grid).gridTemplateColumns;
const n = cols && cols !== 'none' ? cols.split(/\s+/).filter(Boolean).length : 1;
columnCount = Math.max(1, n);
});
// Keep colSpan within the available columns when the grid shrinks.
$effect(() => {
if (colSpan > columnCount) colSpan = columnCount;
});
const featuredGridRow = $derived(rowSpan > 1 ? `span ${rowSpan}` : undefined);
const featuredGridColumn = $derived(colSpan > 1 ? `span ${colSpan}` : undefined);
const autoFlowOptions = ['row', 'column', 'dense', 'row dense', 'column dense'] as const;
const alignOptions = ['stretch', 'start', 'center', 'end', 'baseline'] as const;
@ -32,8 +67,14 @@
'stretch'
] as const;
// Live preview cells — index 0 is the featured (spanning) cell; the rest
// cycle palettes so the grid reads as a real layout.
const cellPalettes = ['primary', 'affirm', 'risk', 'fulfill', 'secondary'] as const;
const cellCount = 10;
const gridProps = $derived<Partial<GridProps>>({
templateColumns: templateColumns || undefined,
autoRows: autoRows || undefined,
autoFlow,
align,
justify,
@ -79,15 +120,16 @@
'</' + 'script>',
'',
'<Grid',
templateColumns !== 'repeat(3, 1fr)' && ` templateColumns="${templateColumns}"`,
templateColumns !== 'repeat(5, 1fr)' && ` templateColumns="${templateColumns}"`,
autoRows && ` autoRows="${autoRows}"`,
autoFlow !== 'row' && ` autoFlow="${autoFlow}"`,
align !== 'stretch' && ` align="${align}"`,
justify !== 'start' && ` justify="${justify}"`,
gap > 0 && ` gap={${gap}}`,
padding > 0 && ` padding={${padding}}`,
'>',
' <Box gridColumn="span 2">A</Box>',
' <Box>B</Box>',
` <Box${featuredGridRow ? ` gridRow="${featuredGridRow}"` : ''}${featuredGridColumn ? ` gridColumn="${featuredGridColumn}"` : ''}>1</Box>`,
' <Box>2</Box>',
' …',
'</Grid>'
]
@ -131,32 +173,18 @@
<div data-uix-stage-area bind:this={stageRef}>
<Grid
{...gridProps}
style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); min-block-size: 10rem; min-inline-size: 24rem;"
width="100%"
style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); min-block-size: 10rem;"
>
<Box
padding={2}
style="border: 1px solid var(--color-primary-border); border-radius: var(--radius-sm); background: var(--color-primary-track); color: var(--color-primary-text); text-align: center;"
>1</Box>
<Box
padding={2}
style="border: 1px solid var(--color-affirm-border); border-radius: var(--radius-sm); background: var(--color-affirm-track); color: var(--color-affirm-text); text-align: center;"
>2</Box>
<Box
padding={2}
style="border: 1px solid var(--color-risk-border); border-radius: var(--radius-sm); background: var(--color-risk-track); color: var(--color-risk-text); text-align: center;"
>3</Box>
<Box
padding={2}
style="border: 1px solid var(--color-primary-border); border-radius: var(--radius-sm); background: var(--color-primary-track); color: var(--color-primary-text); text-align: center;"
>4</Box>
<Box
padding={2}
style="border: 1px solid var(--color-affirm-border); border-radius: var(--radius-sm); background: var(--color-affirm-track); color: var(--color-affirm-text); text-align: center;"
>5</Box>
<Box
padding={2}
style="border: 1px solid var(--color-risk-border); border-radius: var(--radius-sm); background: var(--color-risk-track); color: var(--color-risk-text); text-align: center;"
>6</Box>
{#each Array.from({ length: cellCount }) as _, i}
{@const palette = cellPalettes[i % cellPalettes.length]}
<Box
gridRow={i === 0 ? featuredGridRow : undefined}
gridColumn={i === 0 ? featuredGridColumn : undefined}
padding={2}
style={`border: 1px solid var(--color-${palette}-border); border-radius: var(--radius-sm); background: var(--color-${palette}-track); color: var(--color-${palette}-text); text-align: center; display: grid; place-items: center;${i === 0 ? ' font-weight: 600;' : ''}`}
>{i + 1}</Box>
{/each}
</Grid>
</div>
<div data-uix-stage-trace>
@ -205,7 +233,7 @@
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>templateColumns <span data-uix-control-hint>e.g. repeat(3, 1fr)</span></span>
<span data-uix-control-label>templateColumns <span data-uix-control-hint>repeat(5, 1fr) · try repeat(auto-fit, minmax(4rem, 1fr)) for reflow</span></span>
<input
type="text"
placeholder="(none)"
@ -213,6 +241,15 @@
style="inline-size: 14rem;"
/>
</label>
<label data-uix-control>
<span data-uix-control-label>autoRows <span data-uix-control-hint>row height — makes rowSpan visible</span></span>
<input
type="text"
placeholder="(auto — content height)"
bind:value={autoRows}
style="inline-size: 14rem;"
/>
</label>
<label data-uix-control>
<span data-uix-control-label>autoFlow</span>
<span data-uix-chips role="radiogroup">
@ -260,6 +297,42 @@
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · item placement · cell 1 (child Box)
</div>
<p data-uix-section-desc>
Span lives on the <strong>child Box</strong>, not the Grid — the UIX equivalent of
Chakra's <code>&lt;GridItem rowSpan colSpan&gt;</code>. Cell 1 maps to
<code>gridRow="span {rowSpan}"</code> / <code>gridColumn="span {colSpan}"</code>.
A cell can only span as many columns as the grid has, so <strong>colSpan</strong> is
capped at the <code>templateColumns</code> count (currently <strong>{columnCount}</strong>) —
raise <code>templateColumns</code> to span wider.
</p>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>rowSpan <span data-uix-control-hint>gridRow → span N rows</span></span>
<input
type="number"
min="1"
max="4"
step="1"
bind:value={rowSpan}
style="inline-size: 6rem;"
/>
</label>
<label data-uix-control>
<span data-uix-control-label>colSpan <span data-uix-control-hint>gridColumn → span N cols</span></span>
<input
type="number"
min="1"
max={columnCount}
step="1"
bind:value={colSpan}
style="inline-size: 6rem;"
/>
</label>
</div>
<!-- ── Code snippet (eidos-native — no soma layer) ──────────────── -->
<div data-uix-code>
<div data-uix-code-head>

@ -271,9 +271,9 @@
>
{#snippet children({ months, weekdays }: RangeCalendarProviderSnippetProps)}
<RangeCalendar.Header>
<RangeCalendar.PrevButton aria-label="Previous month">‹</RangeCalendar.PrevButton>
<RangeCalendar.PrevButton aria-label="Previous month" />
<RangeCalendar.Heading />
<RangeCalendar.NextButton aria-label="Next month">›</RangeCalendar.NextButton>
<RangeCalendar.NextButton aria-label="Next month" />
</RangeCalendar.Header>
<!-- Multi-month layout: grids sit side-by-side horizontally via
`grid-auto-flow: column` (mirrors the DateRangePicker demo).

@ -39,7 +39,7 @@
let disabled = $state(false);
let readonly = $state(false);
let lockSeconds = $state(false);
let ruler = $state(true);
let showScales = $state(true);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let bounds = $state(false);
@ -224,8 +224,8 @@
open-state machine and the
<code>open</code>/<code>close-commit</code>/<code>close-cancel</code>/<code>close-dismiss</code>/<code>commit-clear</code>
events; <span data-uix-layer-badge="eidos">eidos</span> adds size /
variant / color recipe, the popover shell, the optional ruler
scale and the canonical <code>Footer</code> composition
variant / color recipe, the popover shell, the optional scales
(ruler) and the canonical <code>Footer</code> composition
(Clear / Cancel / Close). Field-aware:
<code>disabled</code> / <code>readonly</code> / <code>required</code>
OR-merge with the enclosing <code>Field.Provider</code>.
@ -268,7 +268,7 @@
{granularity}
{hourCycle}
{locale}
{ruler}
{showScales}
{size}
{variant}
{color}
@ -456,8 +456,8 @@
<label data-uix-control>
<span data-uix-control-label>clock</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={ruler} />
<span data-uix-switch-label>show ruler scale</span>
<input type="checkbox" bind:checked={showScales} />
<span data-uix-switch-label>show scales</span>
</span>
</label>
</div>
@ -593,7 +593,7 @@
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'sm' | 'md' | 'lg'&gt;</td><td class="default">'md'</td><td>Visual scale.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>—</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">8 system tokens</td><td class="default">'primary'</td><td>—</td></tr>
<tr><td class="name">ruler <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Labelled ruler scale next to each slider.</td></tr>
<tr><td class="name">showScales <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Labelled scale (ruler) next to each slider.</td></tr>
</tbody>
</table>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.