feat(eidos): calendar month/year via popover+grid; nav controls compose system Button/IconButton

- Month/year choosers replace the native `<select>` with a NON-MODAL popover whose
  content is the reused `MonthGrid` / `YearGrid`. Stays non-modal so it also works
  opened inside the date-picker's own popover (no nested modal trap). The viewed
  month/year is highlighted with the selected style; `locale` is threaded and the
  month is capitalized so es ("ene") and en ("Jan") read consistently. `YearGrid`
  gains a `pageAligned` opt so the chooser can start on the selected year.
- Prev / Next compose the system `<IconButton>` (chevron glyph from the primitive);
  the month / year triggers compose the system `<Button>` via the Popover.Trigger
  `child` snippet. The bespoke border / bg / hover / focus / disabled chrome is
  removed from `calendar.css` — it all comes from the primitive now; the trigger
  label tracks the picker font (16px).
- The day grid is centered under the slightly wider header.
- The date-picker reuses these calendar parts, so it inherits all of the above.

WIP (parked, not composed anywhere): the soma `CalendarProvider` also carries an
unused `view` state + `Calendar.MonthView` / `YearView` / `HeadingTrigger` from an
in-place view-switch attempt. The popover approach above is the active one.

Excludes words/palabras/chronos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 36770da202
commit f950973b03

@ -0,0 +1,28 @@
<script lang="ts">
/**
* Eidos `<Calendar.HeadingTrigger>` — the view-switch heading button. Opt-in
* alternative to the static `<Calendar.Heading>`: wraps it in a button that,
* on click, drills UP the navigation view (day → month → year), reusing the
* `MonthGrid` / `YearGrid` rendered by `<Calendar.MonthView>` /
* `<Calendar.YearView>`. Use it only when those views are composed.
*
* It nests the soma `<Calendar.Heading>` so the label, id registration and
* `aria-labelledby` wiring for the grid keep working unchanged.
*/
import type { HTMLButtonAttributes } from 'svelte/elements';
import * as Calendar from '$soma/components/calendar';
import { CalendarProvider } from '$soma/components/calendar';
let { ...rest }: HTMLButtonAttributes = $props();
const provider = CalendarProvider.require();
</script>
<button
type="button"
data-calendar-heading-trigger
onclick={() => provider.advanceView()}
{...rest}
>
<Calendar.Heading />
</button>

@ -1,16 +1,64 @@
<script lang="ts"> <script lang="ts">
import * as Calendar from '$soma/components/calendar'; /**
import type { CalendarMonthSelectProps, CalendarMonthSelectSnippetProps } from './types'; * Eidos `<Calendar.MonthSelect>` — month chooser. A NON-MODAL popover whose
* trigger shows the current month + a chevron and whose content is the reused
* `MonthGrid` (12 month cells). Picking a month sets the calendar month and
* closes. Replaces the former native `<select>`; staying non-modal means it
* also works opened inside the date-picker's own popover (no nested trap).
*
* 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.
*/
import { CalendarProvider } 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 type { MonthValue, MonthGridProviderSnippetProps } from '$soma/components/month-grid';
let { children: bodyContent, ...rest }: CalendarMonthSelectProps = $props(); const provider = CalendarProvider.require();
let open = $state(false);
const placeholderYear = $derived(provider.opts.placeholder.current.year);
const placeholderMonth = $derived(provider.opts.placeholder.current.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 Date(2000, placeholderMonth - 1, 1)
)
);
const placeholder = $derived<MonthValue>({ year: placeholderYear, month: placeholderMonth });
// Highlight the viewed month with the SELECTED style (filled), like the grid.
const value = $derived<MonthValue>({ year: placeholderYear, month: placeholderMonth });
function onValueChange(next: MonthValue | undefined): void {
if (!next) return;
provider.setMonth(next.month);
open = false;
}
</script> </script>
{#if bodyContent} <Popover bind:open modal={false}>
<Calendar.MonthSelect {...rest}> <Popover.Trigger data-calendar-month-select>
{#snippet children(snippetProps: CalendarMonthSelectSnippetProps)} {#snippet child({ props }: { props: Record<string, unknown> })}
{@render bodyContent(snippetProps)} <Button {...props} variant="ghost" size="sm">
<span data-calendar-select-label>{monthLabel}</span>
<ChevronDown data-calendar-select-chevron aria-hidden="true" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-calendar-select-popover>
<MonthGrid {placeholder} {value} locale={provider.effectiveLocale} {onValueChange}>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet} {/snippet}
</Calendar.MonthSelect> </MonthGrid>
{:else} </Popover.Content>
<Calendar.MonthSelect {...rest} /> </Popover.Portal>
{/if} </Popover>

@ -0,0 +1,60 @@
<script lang="ts">
/**
* Eidos `<Calendar.MonthView>` — the month grid shown when the calendar's
* navigation view is `'month'` (Ark/Mantine/MUI in-place view-switch). Reuses
* the eidos `<MonthGrid>`; picking a month sets the calendar's placeholder and
* drills back DOWN to the day grid. The heading is a button that drills UP to
* the year grid. Self-gates on `provider.view`.
*
* The grid drives a LOCAL navigation year (re-seeded from the calendar each
* time this view activates) instead of syncing back into the calendar — that
* decoupling is what avoids the placeholder feedback loop. No `value` is passed
* so the grid never fires a spurious select on mount.
*/
import { CalendarProvider } from '$soma/components/calendar';
import MonthGrid from '../month-grid';
import type { MonthPlaceholder, MonthGridProviderSnippetProps } from '$soma/components/month-grid';
import type { MonthGridSize } from '../month-grid';
let { size = 'md' }: { size?: MonthGridSize } = $props();
const provider = CalendarProvider.require();
// Local navigation year — the grid's own prev/next-year browsing lives here,
// re-seeded from the calendar on each entry into the month view.
let navYear = $state(provider.opts.placeholder.current.year);
$effect(() => {
if (provider.view === 'month') navYear = provider.opts.placeholder.current.year;
});
const placeholder = $derived<MonthPlaceholder>({ year: navYear });
function onPlaceholderChange(next: MonthPlaceholder): void {
navYear = next.year;
}
function onValueChange(next: { year: number; month: number } | undefined): void {
if (next) provider.selectMonthView(next.year, next.month);
}
</script>
{#if provider.view === 'month'}
<MonthGrid {size} {placeholder} {onPlaceholderChange} {onValueChange}>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Header>
<MonthGrid.PrevButton>‹</MonthGrid.PrevButton>
<button
type="button"
data-calendar-view-heading
onclick={() => provider.setView('year')}
>
{navYear}
</button>
<MonthGrid.NextButton>›</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet}
</MonthGrid>
{/if}

@ -1,10 +1,30 @@
<script lang="ts"> <script lang="ts">
/**
* Eidos `Calendar.NextButton` — composes the system `<IconButton>` (which itself
* composes `<Button>`) via soma's `child` snippet, instead of styling a bespoke
* native `<button>`. The forward-chevron glyph plus sizing / hover / focus /
* disabled / a11y all come from the primitive — no duplicated chrome.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar'; import * as Calendar from '$soma/components/calendar';
import { IconButton } from '$uix/eidos/components/icon-button';
import { ChevronRight } from '$uix/eidos/components/icon';
import type { CalendarNextButtonProps } from './types'; import type { CalendarNextButtonProps } from './types';
let { children: bodyContent, ...rest }: CalendarNextButtonProps = $props(); let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: CalendarNextButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.next-month|Next month'));
</script> </script>
<Calendar.NextButton {...rest}> <Calendar.NextButton {...rest}>
{@render bodyContent?.()} {#snippet child({ props }: { props: Record<string, unknown> })}
<IconButton {...props} variant="ghost" size="sm" aria-label={label}>
{#if bodyContent}{@render bodyContent()}{:else}<ChevronRight />{/if}
</IconButton>
{/snippet}
</Calendar.NextButton> </Calendar.NextButton>

@ -1,10 +1,30 @@
<script lang="ts"> <script lang="ts">
/**
* Eidos `Calendar.PrevButton` — composes the system `<IconButton>` (which itself
* composes `<Button>`) via soma's `child` snippet, instead of styling a bespoke
* native `<button>`. The back-chevron glyph plus sizing / hover / focus /
* disabled / a11y all come from the primitive — no duplicated chrome.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar'; import * as Calendar from '$soma/components/calendar';
import { IconButton } from '$uix/eidos/components/icon-button';
import { ChevronLeft } from '$uix/eidos/components/icon';
import type { CalendarPrevButtonProps } from './types'; import type { CalendarPrevButtonProps } from './types';
let { children: bodyContent, ...rest }: CalendarPrevButtonProps = $props(); let {
'aria-label': ariaLabel,
children: bodyContent,
...rest
}: CalendarPrevButtonProps = $props();
const eidos = ActiveEidos.require();
const label = $derived(ariaLabel ?? eidos.langs.ts('#?common.calendar.prev-month|Previous month'));
</script> </script>
<Calendar.PrevButton {...rest}> <Calendar.PrevButton {...rest}>
{@render bodyContent?.()} {#snippet child({ props }: { props: Record<string, unknown> })}
<IconButton {...props} variant="ghost" size="sm" aria-label={label}>
{#if bodyContent}{@render bodyContent()}{:else}<ChevronLeft />{/if}
</IconButton>
{/snippet}
</Calendar.PrevButton> </Calendar.PrevButton>

@ -1,16 +1,80 @@
<script lang="ts"> <script lang="ts">
import * as Calendar from '$soma/components/calendar'; /**
import type { CalendarYearSelectProps, CalendarYearSelectSnippetProps } from './types'; * Eidos `<Calendar.YearSelect>` — year chooser. A NON-MODAL popover whose
* trigger shows the current year + a chevron and whose content is the reused
* `YearGrid` (a decade page with prev/next). Picking a year sets the calendar
* year and closes. Replaces the former native `<select>`.
*
* The decade browsing drives a LOCAL nav-year (re-seeded from the calendar each
* 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.
*/
import { CalendarProvider } 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 type { YearValue, YearPlaceholder, YearGridProviderSnippetProps } from '$soma/components/year-grid';
let { children: bodyContent, ...rest }: CalendarYearSelectProps = $props(); const provider = CalendarProvider.require();
let open = $state(false);
const selectedYear = $derived(provider.opts.placeholder.current.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);
$effect(() => {
if (open) navYear = provider.opts.placeholder.current.year;
});
const yearLabel = $derived(provider.formatYear(selectedYear));
const placeholder = $derived<YearPlaceholder>({ year: navYear });
// Highlight the selected year with the SELECTED (filled) style.
const value = $derived<YearValue>({ year: selectedYear });
function onPlaceholderChange(next: YearPlaceholder): void {
navYear = next.year;
}
function onValueChange(next: YearValue | undefined): void {
if (!next) return;
provider.setYear(next.year);
open = false;
}
</script> </script>
{#if bodyContent} <Popover bind:open modal={false}>
<Calendar.YearSelect {...rest}> <Popover.Trigger data-calendar-year-select>
{#snippet children(snippetProps: CalendarYearSelectSnippetProps)} {#snippet child({ props }: { props: Record<string, unknown> })}
{@render bodyContent(snippetProps)} <Button {...props} variant="ghost" size="sm">
<span data-calendar-select-label>{yearLabel}</span>
<ChevronDown data-calendar-select-chevron aria-hidden="true" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-calendar-select-popover>
<YearGrid
{placeholder}
{value}
pageAligned={false}
locale={provider.effectiveLocale}
{onPlaceholderChange}
{onValueChange}
>
{#snippet children({ years }: YearGridProviderSnippetProps)}
<YearGrid.Header>
<YearGrid.PrevButton><ChevronLeft aria-hidden="true" /></YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton><ChevronRight aria-hidden="true" /></YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/snippet} {/snippet}
</Calendar.YearSelect> </YearGrid>
{:else} </Popover.Content>
<Calendar.YearSelect {...rest} /> </Popover.Portal>
{/if} </Popover>

@ -0,0 +1,50 @@
<script lang="ts">
/**
* Eidos `<Calendar.YearView>` — the year (decade) grid shown when the
* calendar's navigation view is `'year'`. Reuses the eidos `<YearGrid>`;
* picking a year sets the calendar's placeholder year and drills back DOWN to
* the month grid. Top of the drill, so the heading is a plain label.
*
* Drives a LOCAL navigation year (re-seeded on each entry) and passes no
* `value`, mirroring `<Calendar.MonthView>` — avoids the placeholder feedback
* loop and any spurious select on mount.
*/
import { CalendarProvider } from '$soma/components/calendar';
import YearGrid from '../year-grid';
import type { YearPlaceholder, YearGridProviderSnippetProps } from '$soma/components/year-grid';
import type { YearGridSize } from '../year-grid';
let { size = 'md' }: { size?: YearGridSize } = $props();
const provider = CalendarProvider.require();
let navYear = $state(provider.opts.placeholder.current.year);
$effect(() => {
if (provider.view === 'year') navYear = provider.opts.placeholder.current.year;
});
const placeholder = $derived<YearPlaceholder>({ year: navYear });
function onPlaceholderChange(next: YearPlaceholder): void {
navYear = next.year;
}
function onValueChange(next: { year: number } | undefined): void {
if (next) provider.selectYearView(next.year);
}
</script>
{#if provider.view === 'year'}
<YearGrid {size} {placeholder} {onPlaceholderChange} {onValueChange}>
{#snippet children({ years }: YearGridProviderSnippetProps)}
<YearGrid.Header>
<YearGrid.PrevButton>‹</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>›</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/snippet}
</YearGrid>
{/if}

@ -153,84 +153,49 @@
text-align: center; text-align: center;
} }
[data-calendar-prev-button], /* Prev / Next compose the system <IconButton> (see calendar-prev-button.svelte /
[data-calendar-next-button] { calendar-next-button.svelte). Their box, hover, focus-ring, disabled and a11y
display: inline-flex; all come from the primitive — no bespoke chrome here. */
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-calendar-prev-button]:hover,
[data-calendar-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-calendar-prev-button]:focus-visible,
[data-calendar-next-button]:focus-visible,
[data-calendar-month-select]:focus-visible,
[data-calendar-year-select]:focus-visible,
[data-calendar-day]:focus-visible { [data-calendar-day]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color); outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0; outline-offset: 0;
} }
[data-calendar-prev-button][data-disabled], /* The month / year trigger is now the system <Button> (composed via the
[data-calendar-next-button][data-disabled], Popover.Trigger `child` snippet). Its box / border / bg / hover / focus /
[data-calendar-month-select][data-disabled], disabled all come from the primitive — only the label font + chevron remain. */
[data-calendar-year-select][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-calendar-month-select], [data-calendar-select-label] {
[data-calendar-year-select] { /* The Button's sm size is 14px; bump the label to the picker font (16px) so
min-inline-size: 0; the month/year text matches the calendar. Set on the label (not the Button
block-size: var(--_calendar-control-size); token) so it wins over the inherited Button font without a specificity war. */
padding-inline: var(--calendar-select-px); font-size: var(--_calendar-font-size);
border: var(--calendar-control-border-width) solid var(--calendar-control-border); white-space: nowrap;
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
font: inherit;
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-calendar-month-select] { [data-calendar-select-chevron] {
min-inline-size: var(--calendar-select-min-width); flex: 0 0 auto;
inline-size: calc(var(--_calendar-day-size) * 2.35); inline-size: var(--_calendar-font-size);
block-size: var(--_calendar-font-size);
opacity: 0.6;
} }
[data-calendar-year-select] { /* The grid sits inside the popover's own surface — flatten its standalone
min-inline-size: var(--calendar-select-min-width); surface (border / bg / shadow / outer padding) so the panel isn't doubled. */
inline-size: calc(var(--_calendar-day-size) * 2.15); [data-calendar-select-popover] :is([data-month-grid], [data-year-grid]) {
border: none;
background: transparent;
box-shadow: none;
padding: 0;
} }
[data-calendar-month-select]:hover, /* Spanish (and other locales') Intl month abbreviations come back lowercase
[data-calendar-year-select]:hover { ("ene", "feb"); capitalize so the month reads the same regardless of locale —
border-color: var(--calendar-control-border-hover); matching the English "Jan" / "Feb". */
background: var(--calendar-control-bg-hover); [data-calendar-month-select],
color: var(--calendar-control-color-hover); [data-calendar-select-popover] [data-month-grid-cell] {
text-transform: capitalize;
} }
[data-calendar-grid] { [data-calendar-grid] {

@ -1,10 +1,13 @@
import CalendarComponent from './calendar.svelte' import CalendarComponent from './calendar.svelte'
import Header from './calendar-header.svelte' import Header from './calendar-header.svelte'
import Heading from './calendar-heading.svelte' import Heading from './calendar-heading.svelte'
import HeadingTrigger from './calendar-heading-trigger.svelte'
import PrevButton from './calendar-prev-button.svelte' import PrevButton from './calendar-prev-button.svelte'
import NextButton from './calendar-next-button.svelte' import NextButton from './calendar-next-button.svelte'
import MonthSelect from './calendar-month-select.svelte' import MonthSelect from './calendar-month-select.svelte'
import YearSelect from './calendar-year-select.svelte' import YearSelect from './calendar-year-select.svelte'
import MonthView from './calendar-month-view.svelte'
import YearView from './calendar-year-view.svelte'
import Grid from './calendar-grid.svelte' import Grid from './calendar-grid.svelte'
import GridHead from './calendar-grid-head.svelte' import GridHead from './calendar-grid-head.svelte'
import GridBody from './calendar-grid-body.svelte' import GridBody from './calendar-grid-body.svelte'
@ -16,10 +19,13 @@ import Day from './calendar-day.svelte'
type CalendarNamespace = typeof CalendarComponent & { type CalendarNamespace = typeof CalendarComponent & {
Header: typeof Header Header: typeof Header
Heading: typeof Heading Heading: typeof Heading
HeadingTrigger: typeof HeadingTrigger
PrevButton: typeof PrevButton PrevButton: typeof PrevButton
NextButton: typeof NextButton NextButton: typeof NextButton
MonthSelect: typeof MonthSelect MonthSelect: typeof MonthSelect
YearSelect: typeof YearSelect YearSelect: typeof YearSelect
MonthView: typeof MonthView
YearView: typeof YearView
Grid: typeof Grid Grid: typeof Grid
GridHead: typeof GridHead GridHead: typeof GridHead
GridBody: typeof GridBody GridBody: typeof GridBody
@ -32,10 +38,13 @@ type CalendarNamespace = typeof CalendarComponent & {
const Calendar = CalendarComponent as CalendarNamespace const Calendar = CalendarComponent as CalendarNamespace
Calendar.Header = Header Calendar.Header = Header
Calendar.Heading = Heading Calendar.Heading = Heading
Calendar.HeadingTrigger = HeadingTrigger
Calendar.PrevButton = PrevButton Calendar.PrevButton = PrevButton
Calendar.NextButton = NextButton Calendar.NextButton = NextButton
Calendar.MonthSelect = MonthSelect Calendar.MonthSelect = MonthSelect
Calendar.YearSelect = YearSelect Calendar.YearSelect = YearSelect
Calendar.MonthView = MonthView
Calendar.YearView = YearView
Calendar.Grid = Grid Calendar.Grid = Grid
Calendar.GridHead = GridHead Calendar.GridHead = GridHead
Calendar.GridBody = GridBody Calendar.GridBody = GridBody

@ -38,6 +38,7 @@ import type {
CalendarCellSnippetProps, CalendarCellSnippetProps,
CalendarDaySnippetProps, CalendarDaySnippetProps,
CalendarMonthSelectSnippetProps, CalendarMonthSelectSnippetProps,
CalendarView,
CalendarYearSelectSnippetProps CalendarYearSelectSnippetProps
} from './types'; } from './types';
@ -133,6 +134,13 @@ export class CalendarProvider {
headingId = state(''); headingId = state('');
/** Live-region text for announcements. */ /** Live-region text for announcements. */
announcement = $state(''); announcement = $state('');
/**
* Navigation view (Ark/Mantine/MUI pattern): the day grid switches in-place
* to a month grid then a year grid for fast navigation — no native `<select>`,
* no nested popover. The `MonthView` / `YearView` parts render the reused
* `MonthGrid` / `YearGrid` when active; the `Heading` advances the view.
*/
view = $state<CalendarView>('day');
private lastLoggedValidationKey: string | null = null; private lastLoggedValidationKey: string | null = null;
private constructor(opts: CalendarOpts) { private constructor(opts: CalendarOpts) {
@ -347,6 +355,29 @@ export class CalendarProvider {
this.setPlaceholder(this.opts.placeholder.current.set({ year }), target); this.setPlaceholder(this.opts.placeholder.current.set({ year }), target);
} }
// ── Navigation view (day ↔ month ↔ year) ──────────────────────────────────
setView(view: CalendarView): void {
this.view = view;
}
/** Heading click: drill UP one level (day → month → year). */
advanceView(): void {
this.view = this.view === 'day' ? 'month' : 'year';
}
/** Month grid select: set the placeholder year+month and drill back DOWN to days. */
selectMonthView(year: number, month: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ year, month }), target);
this.view = 'day';
}
/** Year grid select: set the placeholder year and drill back DOWN to months. */
selectYearView(year: number, target?: HTMLElement): void {
this.setYear(year, target);
this.view = 'month';
}
private announceNavigation(): void { private announceNavigation(): void {
if (!this.opts.announceNavigation.current) return; if (!this.opts.announceNavigation.current) return;
this.announcement = this.headingValue; this.announcement = this.headingValue;

@ -13,7 +13,13 @@ export { default as HeadCell } from './components/calendar-head-cell.svelte';
export { default as Cell } from './components/calendar-cell.svelte'; export { default as Cell } from './components/calendar-cell.svelte';
export { default as Day } from './components/calendar-day.svelte'; export { default as Day } from './components/calendar-day.svelte';
// The provider class is exported so the eidos view-switch parts
// (Heading button, MonthView, YearView) can read `view` + drive
// `advanceView` / `selectMonthView` / `selectYearView` off the live context.
export { CalendarProvider } from './calendar-provider.svelte';
export type { export type {
CalendarView,
CalendarProps, CalendarProps,
CalendarHeaderProps, CalendarHeaderProps,
CalendarHeadingProps, CalendarHeadingProps,

@ -15,6 +15,15 @@ import type {
import type { DateValue, DateMatcher, Month, WeekStartsOn, DateOrder } from '$libs/days'; import type { DateValue, DateMatcher, Month, WeekStartsOn, DateOrder } from '$libs/days';
import type { CalendarBoundsValidationStatus } from './helpers'; import type { CalendarBoundsValidationStatus } from './helpers';
// ── Navigation view ──────────────────────────────────────────────────────────
/**
* The calendar's active navigation view. The day grid switches in-place to a
* month grid then a year grid (Ark/Mantine/MUI pattern) — no native `<select>`,
* no nested popover.
*/
export type CalendarView = 'day' | 'month' | 'year';
// ── Snippet props ──────────────────────────────────────────────────────────── // ── Snippet props ────────────────────────────────────────────────────────────
/** Snippet props exposed by the root `Calendar.Provider`. */ /** Snippet props exposed by the root `Calendar.Provider`. */

@ -26,6 +26,7 @@
dir, dir,
locale, locale,
decadeSize = 12, decadeSize = 12,
pageAligned = true,
ariaLabel, ariaLabel,
children, children,
child, child,
@ -59,6 +60,7 @@
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'), dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
locale: readableActive(() => locale ?? ''), locale: readableActive(() => locale ?? ''),
decadeSize: readableActive(() => decadeSize), decadeSize: readableActive(() => decadeSize),
pageAligned: readableActive(() => pageAligned),
ariaLabel: readableActive(() => ariaLabel) ariaLabel: readableActive(() => ariaLabel)
}); });

@ -75,6 +75,12 @@ type YearGridBaseProps = {
* @default 12 * @default 12
*/ */
decadeSize?: number; decadeSize?: number;
/**
* When `true` (default) the page aligns to a `decadeSize` boundary (a window of
* past + future years). When `false` the page starts at the placeholder year.
* @default true
*/
pageAligned?: boolean;
/** `aria-label` for the provider root. Localized when omitted. */ /** `aria-label` for the provider root. Localized when omitted. */
ariaLabel?: string; ariaLabel?: string;
}; };

@ -46,6 +46,14 @@ export interface YearGridOpts
dir: 'ltr' | 'rtl'; dir: 'ltr' | 'rtl';
locale: string; locale: string;
decadeSize: number; decadeSize: number;
/**
* When `true` (default) the page aligns to a `decadeSize` boundary
* (e.g. 2016–2027 for 2026) — a window of past + future years, best for
* picking a distant year. When `false` the page STARTS at the placeholder
* year (2026–2037) — best when the grid opens on the current value and
* forward years matter most (e.g. a calendar's year chooser).
*/
pageAligned: boolean;
ariaLabel: string | undefined; ariaLabel: string | undefined;
}> { }> {
onValueChange?: OnChangeFn<YearValue | undefined>; onValueChange?: OnChangeFn<YearValue | undefined>;
@ -99,6 +107,7 @@ export class YearGridProvider {
readonly pageStart = $derived.by(() => { readonly pageStart = $derived.by(() => {
const placeholder = this.opts.placeholder.current.year; const placeholder = this.opts.placeholder.current.year;
if (this.opts.pageAligned.current === false) return placeholder;
const size = this.decadeSize; const size = this.decadeSize;
return placeholder - ((placeholder % size + size) % size); return placeholder - ((placeholder % size + size) % size);
}); });

@ -388,15 +388,15 @@
{#snippet calendarBody({ months, weekdays, validationMessage }: CalendarProviderSnippetProps)} {#snippet calendarBody({ months, weekdays, validationMessage }: CalendarProviderSnippetProps)}
<Calendar.Header> <Calendar.Header>
<Calendar.PrevButton aria-label="Previous month">&lt;</Calendar.PrevButton> <Calendar.PrevButton aria-label="Previous month" />
<div data-calendar-header-controls> <div data-calendar-header-controls>
<Calendar.MonthSelect /> <Calendar.MonthSelect />
<Calendar.YearSelect /> <Calendar.YearSelect />
</div> </div>
<Calendar.NextButton aria-label="Next month">&gt;</Calendar.NextButton> <Calendar.NextButton aria-label="Next month" />
</Calendar.Header> </Calendar.Header>
<div <div
style={`display:grid;grid-template-columns:repeat(${numberOfMonths},max-content);gap:var(--uix-space-4);align-items:start;max-inline-size:100%;overflow:auto;`} style={`display:grid;grid-template-columns:repeat(${numberOfMonths},max-content);gap:var(--uix-space-4);align-items:start;justify-content:safe center;max-inline-size:100%;overflow:auto;`}
> >
{#each months as month (month.value.toString())} {#each months as month (month.value.toString())}
<div data-calendar-month-panel> <div data-calendar-month-panel>

@ -1196,12 +1196,12 @@
{#snippet calendarBody({ months, weekdays, validationMessage }: DatePickerCalendarSnippetProps)} {#snippet calendarBody({ months, weekdays, validationMessage }: DatePickerCalendarSnippetProps)}
<DatePicker.Header> <DatePicker.Header>
<DatePicker.PrevButton aria-label="Previous month">&lt;</DatePicker.PrevButton> <DatePicker.PrevButton aria-label="Previous month" />
<div data-calendar-header-controls> <div data-calendar-header-controls>
<DatePicker.MonthSelect /> <DatePicker.MonthSelect />
<DatePicker.YearSelect /> <DatePicker.YearSelect />
</div> </div>
<DatePicker.NextButton aria-label="Next month">&gt;</DatePicker.NextButton> <DatePicker.NextButton aria-label="Next month" />
</DatePicker.Header> </DatePicker.Header>
{#if validationMessage} {#if validationMessage}

Loading…
Cancel
Save

Powered by TurnKey Linux.