From 92f988e7e7ec713cf0ab4a9cd06c5c863ebd4337 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 26 Jun 2026 12:30:10 +0200 Subject: [PATCH] feat(uix): calendar-nav unification, TimePicker datetime blocks, A31 perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CONTINUE.md | 65 +++++++++ src/uix/eidos/archetypes.css | 21 +-- .../calendar/calendar-month-select.svelte | 29 ++-- .../calendar/calendar-nav-button.svelte | 34 +++++ .../calendar/calendar-next-button.svelte | 7 +- .../calendar/calendar-prev-button.svelte | 7 +- .../components/calendar/calendar-select.css | 45 ++++++ .../calendar/calendar-year-select.svelte | 26 ++-- .../eidos/components/calendar/calendar.css | 39 +---- .../eidos/components/calendar/calendar.svelte | 3 + .../components/calendar/visual-context.ts | 24 ++++ .../components/code-block/code-block.css | 36 +---- .../components/code-block/code-block.svelte | 47 ++----- .../date-picker/date-picker-calendar.svelte | 3 + .../components/date-picker/date-picker.css | 22 +++ .../date-range-picker-month-select.svelte | 19 --- .../date-range-picker-next-button.svelte | 20 ++- .../date-range-picker-prev-button.svelte | 20 ++- .../date-range-picker-year-select.svelte | 19 --- .../components/date-range-picker/index.ts | 7 +- .../eidos/components/range-calendar/index.ts | 7 +- .../range-calendar-month-select.svelte | 19 --- .../range-calendar-next-button.svelte | 20 ++- .../range-calendar-prev-button.svelte | 20 ++- .../range-calendar-year-select.svelte | 19 --- .../range-calendar/range-calendar.css | 43 +----- .../range-calendar/range-calendar.svelte | 3 + .../components/time-picker/context.svelte.ts | 17 ++- .../time-picker-day-period-toggle.svelte | 36 ++++- .../time-picker-hour-slider.svelte | 12 +- .../time-picker-minute-slider.svelte | 54 +++---- .../time-picker-second-slider.svelte | 54 +++---- .../components/time-picker/time-picker.css | 34 ++++- .../components/time-picker/time-picker.svelte | 7 +- src/uix/eidos/components/time-picker/types.ts | 8 +- src/uix/langs/components/index.ts | 6 + src/uix/langs/components/metrics.ts | 28 ++++ src/uix/langs/components/picker-shell.ts | 24 ++++ .../soma/components/calendar/calendar-nav.ts | 30 ++++ .../calendar/calendar-provider.svelte.ts | 13 +- src/uix/soma/components/calendar/exports.ts | 6 + .../checkbox/checkbox-provider.svelte.ts | 5 +- .../combobox/combobox-provider.svelte.ts | 5 +- .../grid-list/grid-list-provider.svelte.ts | 5 +- .../listbox/listbox-provider.svelte.ts | 10 +- .../popover/popover-provider.svelte.ts | 18 +++ .../range-calendar-provider.svelte.ts | 12 +- .../select/select-provider.svelte.ts | 5 +- .../tag-group/tag-group-provider.svelte.ts | 5 +- .../time-picker-day-period-toggle.svelte | 45 +++++- .../toggle-group-provider.svelte.ts | 5 +- .../tree-grid/tree-grid-provider.svelte.ts | 10 +- .../tree-view/tree-view-provider.svelte.ts | 10 +- .../uix/components/date-picker/+page.svelte | 89 +++++++++--- .../components/date-range-picker/+page.svelte | 9 +- web/routes/uix/components/field/+page.svelte | 2 +- web/routes/uix/components/grid/+page.svelte | 133 ++++++++++++++---- .../components/range-calendar/+page.svelte | 4 +- .../uix/components/time-picker/+page.svelte | 14 +- 59 files changed, 928 insertions(+), 411 deletions(-) create mode 100644 CONTINUE.md create mode 100644 src/uix/eidos/components/calendar/calendar-nav-button.svelte create mode 100644 src/uix/eidos/components/calendar/calendar-select.css create mode 100644 src/uix/eidos/components/calendar/visual-context.ts delete mode 100644 src/uix/eidos/components/date-range-picker/date-range-picker-month-select.svelte delete mode 100644 src/uix/eidos/components/date-range-picker/date-range-picker-year-select.svelte delete mode 100644 src/uix/eidos/components/range-calendar/range-calendar-month-select.svelte delete mode 100644 src/uix/eidos/components/range-calendar/range-calendar-year-select.svelte create mode 100644 src/uix/langs/components/metrics.ts create mode 100644 src/uix/langs/components/picker-shell.ts create mode 100644 src/uix/soma/components/calendar/calendar-nav.ts diff --git a/CONTINUE.md b/CONTINUE.md new file mode 100644 index 000000000..afbc75c37 --- /dev/null +++ b/CONTINUE.md @@ -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 `` 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 + `` 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 `` (drop the local bridge/CSS); + date-range-picker gains a granularity control + ``. + +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 `` (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. diff --git a/src/uix/eidos/archetypes.css b/src/uix/eidos/archetypes.css index 43d716c28..dff17fa5e 100644 --- a/src/uix/eidos/archetypes.css +++ b/src/uix/eidos/archetypes.css @@ -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); } diff --git a/src/uix/eidos/components/calendar/calendar-month-select.svelte b/src/uix/eidos/components/calendar/calendar-month-select.svelte index 04350eb44..769187fd4 100644 --- a/src/uix/eidos/components/calendar/calendar-month-select.svelte +++ b/src/uix/eidos/components/calendar/calendar-month-select.svelte @@ -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; } @@ -42,7 +49,7 @@ {#snippet child({ props }: { props: Record })} - @@ -50,7 +57,13 @@ - + {#snippet children({ months }: MonthGridProviderSnippetProps)} {#each months as month (month)} diff --git a/src/uix/eidos/components/calendar/calendar-nav-button.svelte b/src/uix/eidos/components/calendar/calendar-nav-button.svelte new file mode 100644 index 000000000..208e910be --- /dev/null +++ b/src/uix/eidos/components/calendar/calendar-nav-button.svelte @@ -0,0 +1,34 @@ + + + + {#if children} + {@render children()} + {:else if direction === 'prev'} + + {:else} + + {/if} + diff --git a/src/uix/eidos/components/calendar/calendar-next-button.svelte b/src/uix/eidos/components/calendar/calendar-next-button.svelte index 06c0f9650..830becca5 100644 --- a/src/uix/eidos/components/calendar/calendar-next-button.svelte +++ b/src/uix/eidos/components/calendar/calendar-next-button.svelte @@ -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 @@ {#snippet child({ props }: { props: Record })} - - {#if bodyContent}{@render bodyContent()}{:else}{/if} - + {/snippet} diff --git a/src/uix/eidos/components/calendar/calendar-prev-button.svelte b/src/uix/eidos/components/calendar/calendar-prev-button.svelte index ebb8861ea..4a46d5743 100644 --- a/src/uix/eidos/components/calendar/calendar-prev-button.svelte +++ b/src/uix/eidos/components/calendar/calendar-prev-button.svelte @@ -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 @@ {#snippet child({ props }: { props: Record })} - - {#if bodyContent}{@render bodyContent()}{:else}{/if} - + {/snippet} diff --git a/src/uix/eidos/components/calendar/calendar-select.css b/src/uix/eidos/components/calendar/calendar-select.css new file mode 100644 index 000000000..1ad3deab9 --- /dev/null +++ b/src/uix/eidos/components/calendar/calendar-select.css @@ -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; +} diff --git a/src/uix/eidos/components/calendar/calendar-year-select.svelte b/src/uix/eidos/components/calendar/calendar-year-select.svelte index 5a70f6c55..5765effb3 100644 --- a/src/uix/eidos/components/calendar/calendar-year-select.svelte +++ b/src/uix/eidos/components/calendar/calendar-year-select.svelte @@ -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({ year: navYear }); // Highlight the selected year with the SELECTED (filled) style. const value = $derived({ 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; } @@ -46,7 +53,7 @@ {#snippet child({ props }: { props: Record })} - @@ -55,10 +62,11 @@ diff --git a/src/uix/eidos/components/calendar/calendar.css b/src/uix/eidos/components/calendar/calendar.css index a489609d7..4bcd57403 100644 --- a/src/uix/eidos/components/calendar/calendar.css +++ b/src/uix/eidos/components/calendar/calendar.css @@ -162,41 +162,10 @@ outline-offset: 0; } -/* The month / year trigger is now the system + + + + {/if} {/if} diff --git a/src/uix/eidos/components/date-picker/date-picker-calendar.svelte b/src/uix/eidos/components/date-picker/date-picker-calendar.svelte index 4d92b0cb5..e8657abd3 100644 --- a/src/uix/eidos/components/date-picker/date-picker-calendar.svelte +++ b/src/uix/eidos/components/date-picker/date-picker-calendar.svelte @@ -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 }); - import * as DateRangePicker from '$soma/components/date-range-picker'; - import type { - DateRangePickerMonthSelectProps, - DateRangePickerMonthSelectSnippetProps - } from './types'; - - let { children: bodyContent, ...rest }: DateRangePickerMonthSelectProps = $props(); - - -{#if bodyContent} - - {#snippet children(snippetProps: DateRangePickerMonthSelectSnippetProps)} - {@render bodyContent(snippetProps)} - {/snippet} - -{:else} - -{/if} diff --git a/src/uix/eidos/components/date-range-picker/date-range-picker-next-button.svelte b/src/uix/eidos/components/date-range-picker/date-range-picker-next-button.svelte index b27fcb5e7..55bd3908b 100644 --- a/src/uix/eidos/components/date-range-picker/date-range-picker-next-button.svelte +++ b/src/uix/eidos/components/date-range-picker/date-range-picker-next-button.svelte @@ -1,10 +1,26 @@ - {@render children?.()} + {#snippet child({ props }: { props: Record })} + + {/snippet} diff --git a/src/uix/eidos/components/date-range-picker/date-range-picker-prev-button.svelte b/src/uix/eidos/components/date-range-picker/date-range-picker-prev-button.svelte index 7657801c2..722e4d1f2 100644 --- a/src/uix/eidos/components/date-range-picker/date-range-picker-prev-button.svelte +++ b/src/uix/eidos/components/date-range-picker/date-range-picker-prev-button.svelte @@ -1,10 +1,26 @@ - {@render children?.()} + {#snippet child({ props }: { props: Record })} + + {/snippet} diff --git a/src/uix/eidos/components/date-range-picker/date-range-picker-year-select.svelte b/src/uix/eidos/components/date-range-picker/date-range-picker-year-select.svelte deleted file mode 100644 index ede59f9d4..000000000 --- a/src/uix/eidos/components/date-range-picker/date-range-picker-year-select.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -{#if bodyContent} - - {#snippet children(snippetProps: DateRangePickerYearSelectSnippetProps)} - {@render bodyContent(snippetProps)} - {/snippet} - -{:else} - -{/if} diff --git a/src/uix/eidos/components/date-range-picker/index.ts b/src/uix/eidos/components/date-range-picker/index.ts index b21631fdb..efb02021f 100644 --- a/src/uix/eidos/components/date-range-picker/index.ts +++ b/src/uix/eidos/components/date-range-picker/index.ts @@ -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'; diff --git a/src/uix/eidos/components/range-calendar/index.ts b/src/uix/eidos/components/range-calendar/index.ts index 6224bdada..c013f6f60 100644 --- a/src/uix/eidos/components/range-calendar/index.ts +++ b/src/uix/eidos/components/range-calendar/index.ts @@ -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'; diff --git a/src/uix/eidos/components/range-calendar/range-calendar-month-select.svelte b/src/uix/eidos/components/range-calendar/range-calendar-month-select.svelte deleted file mode 100644 index 127b29879..000000000 --- a/src/uix/eidos/components/range-calendar/range-calendar-month-select.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -{#if bodyContent} - - {#snippet children(snippetProps: RangeCalendarMonthSelectSnippetProps)} - {@render bodyContent(snippetProps)} - {/snippet} - -{:else} - -{/if} diff --git a/src/uix/eidos/components/range-calendar/range-calendar-next-button.svelte b/src/uix/eidos/components/range-calendar/range-calendar-next-button.svelte index 0b8832c5c..bb8157910 100644 --- a/src/uix/eidos/components/range-calendar/range-calendar-next-button.svelte +++ b/src/uix/eidos/components/range-calendar/range-calendar-next-button.svelte @@ -1,10 +1,26 @@ - {@render bodyContent?.()} + {#snippet child({ props }: { props: Record })} + + {/snippet} diff --git a/src/uix/eidos/components/range-calendar/range-calendar-prev-button.svelte b/src/uix/eidos/components/range-calendar/range-calendar-prev-button.svelte index 739abf7c6..e198ade06 100644 --- a/src/uix/eidos/components/range-calendar/range-calendar-prev-button.svelte +++ b/src/uix/eidos/components/range-calendar/range-calendar-prev-button.svelte @@ -1,10 +1,26 @@ - {@render bodyContent?.()} + {#snippet child({ props }: { props: Record })} + + {/snippet} diff --git a/src/uix/eidos/components/range-calendar/range-calendar-year-select.svelte b/src/uix/eidos/components/range-calendar/range-calendar-year-select.svelte deleted file mode 100644 index b9fc19be4..000000000 --- a/src/uix/eidos/components/range-calendar/range-calendar-year-select.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -{#if bodyContent} - - {#snippet children(snippetProps: RangeCalendarYearSelectSnippetProps)} - {@render bodyContent(snippetProps)} - {/snippet} - -{:else} - -{/if} diff --git a/src/uix/eidos/components/range-calendar/range-calendar.css b/src/uix/eidos/components/range-calendar/range-calendar.css index 56204fd3e..bacaa6aed 100644 --- a/src/uix/eidos/components/range-calendar/range-calendar.css +++ b/src/uix/eidos/components/range-calendar/range-calendar.css @@ -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 (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; diff --git a/src/uix/eidos/components/range-calendar/range-calendar.svelte b/src/uix/eidos/components/range-calendar/range-calendar.svelte index ff63be840..2ecaef1c8 100644 --- a/src/uix/eidos/components/range-calendar/range-calendar.svelte +++ b/src/uix/eidos/components/range-calendar/range-calendar.svelte @@ -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 }); ` 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 `` 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 { diff --git a/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte b/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte index 562d2dc43..88702c7bb 100644 --- a/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte +++ b/src/uix/eidos/components/time-picker/time-picker-day-period-toggle.svelte @@ -1,17 +1,45 @@ - {@render children?.()} + {#snippet child({ props, value, setValue, disabled, items })} + { + 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)} + {item.label} + {/each} + + {/snippet} diff --git a/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte index c0547b282..aa6a3d196 100644 --- a/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte +++ b/src/uix/eidos/components/time-picker/time-picker-hour-slider.svelte @@ -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 @@
- {#if showRuler} + {#if scalesVisible} {#snippet children({ ticks, percent, label })} {#each ticks as v} diff --git a/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte index 961452d9f..84934c658 100644 --- a/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte +++ b/src/uix/eidos/components/time-picker/time-picker-minute-slider.svelte @@ -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 @@ } -
- - - {#if children} - {@render children?.()} - {:else} - - +{#if granularity !== 'hour'} +
+ + + {#if children} + {@render children?.()} + {:else} + + + {/if} + + {#if scalesVisible} + {/if} - - {#if showRuler} - - {/if} -
+
+{/if} diff --git a/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte b/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte index 46ca0acb4..2329ce080 100644 --- a/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte +++ b/src/uix/eidos/components/time-picker/time-picker-second-slider.svelte @@ -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 @@ } -
- - - {#if children} - {@render children?.()} - {:else} - - +{#if granularity === 'second'} +
+ + + {#if children} + {@render children?.()} + {:else} + + + {/if} + + {#if scalesVisible} + {/if} - - {#if showRuler} - - {/if} -
+
+{/if} diff --git a/src/uix/eidos/components/time-picker/time-picker.css b/src/uix/eidos/components/time-picker/time-picker.css index fc239e55c..75c77bbbb 100644 --- a/src/uix/eidos/components/time-picker/time-picker.css +++ b/src/uix/eidos/components/time-picker/time-picker.css @@ -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; diff --git a/src/uix/eidos/components/time-picker/time-picker.svelte b/src/uix/eidos/components/time-picker/time-picker.svelte index 006a5e08f..3c3d7f423 100644 --- a/src/uix/eidos/components/time-picker/time-picker.svelte +++ b/src/uix/eidos/components/time-picker/time-picker.svelte @@ -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 @@ +export type TimePickerSize = Extract 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 & { diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index eb439e34a..4772f624f 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -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, diff --git a/src/uix/langs/components/metrics.ts b/src/uix/langs/components/metrics.ts new file mode 100644 index 000000000..163eb695e --- /dev/null +++ b/src/uix/langs/components/metrics.ts @@ -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; diff --git a/src/uix/langs/components/picker-shell.ts b/src/uix/langs/components/picker-shell.ts new file mode 100644 index 000000000..b577794c5 --- /dev/null +++ b/src/uix/langs/components/picker-shell.ts @@ -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; diff --git a/src/uix/soma/components/calendar/calendar-nav.ts b/src/uix/soma/components/calendar/calendar-nav.ts new file mode 100644 index 000000000..455bb3046 --- /dev/null +++ b/src/uix/soma/components/calendar/calendar-nav.ts @@ -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'); diff --git a/src/uix/soma/components/calendar/calendar-provider.svelte.ts b/src/uix/soma/components/calendar/calendar-provider.svelte.ts index e626ab4a7..741717b01 100644 --- a/src/uix/soma/components/calendar/calendar-provider.svelte.ts +++ b/src/uix/soma/components/calendar/calendar-provider.svelte.ts @@ -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 { diff --git a/src/uix/soma/components/calendar/exports.ts b/src/uix/soma/components/calendar/exports.ts index c997f8bc5..462564834 100644 --- a/src/uix/soma/components/calendar/exports.ts +++ b/src/uix/soma/components/calendar/exports.ts @@ -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, diff --git a/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts b/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts index 169b0e92d..d9f996237 100644 --- a/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts +++ b/src/uix/soma/components/checkbox/checkbox-provider.svelte.ts @@ -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) { diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index e43f62136..4171044f3 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -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. */ diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts index 312efb609..ca016920f 100644 --- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts @@ -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') { diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts index b6471b735..5c03614e9 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts @@ -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') { diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index c89af48b2..989f33b97 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -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'); }), diff --git a/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts index e24439b5a..8ecc86b21 100644 --- a/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts +++ b/src/uix/soma/components/range-calendar/range-calendar-provider.svelte.ts @@ -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; diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index ef28df551..74fea13c2 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -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 { diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts index 8f9240292..cb3e150b4 100644 --- a/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.ts @@ -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) { diff --git a/src/uix/soma/components/time-picker/components/time-picker-day-period-toggle.svelte b/src/uix/soma/components/time-picker/components/time-picker-day-period-toggle.svelte index 4a665ff9c..69e926dc1 100644 --- a/src/uix/soma/components/time-picker/components/time-picker-day-period-toggle.svelte +++ b/src/uix/soma/components/time-picker/components/time-picker-day-period-toggle.svelte @@ -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. ``) wired to the AM/PM state, instead of the + * bespoke native radios rendered by the no-child default below. + */ + type DayPeriodChildArgs = { + props: Record; + value: 'AM' | 'PM'; + setValue: (next: 'AM' | 'PM') => void; + disabled: boolean; + items: { value: 'AM' | 'PM'; label: string }[]; + }; + + type TimePickerDayPeriodToggleProps = { id?: string; - }> & - Without; + children?: Snippet; + child?: Snippet<[DayPeriodChildArgs]>; + } & Without; const uid = $props.id(); @@ -41,11 +56,31 @@ 'aria-disabled': disabled || undefined } as Record) ); + + // 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) + ); + + const items = $derived([ + { value: 'AM' as const, label: amLabel }, + { value: 'PM' as const, label: pmLabel } + ]); {#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}
+