refactor(date pickers): promote MonthView/YearView to soma parts (audit 5.3 P4)

Eliminates `Provider.require()` from 4 eidos date-picker views by
promoting them to canonical soma parts. Architectural 3-layer split:

  $libs/days  →  pure date math
  soma        →  state machine + behavior
  eidos       →  paint (pure passthrough)

Pure-math helpers added to $libs/days (queries.ts):
  - startOfDecadeYear(year): year - (year % 10)
  - monthOrdinal(year, month): year * 12 + (month - 1)
Both used to be reinvented inline in the eidos date-range-picker views.

New soma parts:
  - <DatePicker.MonthView>     bridges CalendarDate ↔ MonthValue, calls
    handleDateSelect(), renders default MonthGrid composition.
  - <DatePicker.YearView>      symmetric bridge to YearGrid.
  - <DateRangePicker.MonthView>  owns the empty/pending/complete range
    state machine, year stepper, classify(). Endpoints normalise to
    day=1 (start) and endOfMonth(...) (end).
  - <DateRangePicker.YearView>   owns the range state machine with
    decade stepper. Year endpoints normalise to Jan 1 / Dec 31.

All four parts declared in their morfos with `kind: 'public', optional`.
The range views stamp `role='grid'` + canonical aria-label.

Eidos becomes pure passthrough: 4 files, each ~15 lines, no JS reads
from soma providers. Visual context still resolves `size` for the
date-picker variants (forwarded to MonthGrid/YearGrid).

Test result unchanged: 2391/2397 passing. No regressions.

This closes audit 5.3 Option C — all 8 sites refactored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent a5243168e5
commit e93c0c7337

@ -72,3 +72,27 @@ export function isDiasZonedDateTime(value: unknown): value is ZonedDateTime {
}
export { isDiasZonedDateTime as isZonedDateTime };
/**
* Returns the first year of the decade containing `year` (e.g. 2026 →
* 2020, 1999 → 1990). Pure number math, no DateValue allocation.
*
* Used by year-grid range pickers to anchor a 10-year window for
* decade-based pagination.
*/
export function startOfDecadeYear(year: number): number {
return year - (year % 10);
}
/**
* Encodes a `{ year, month }` pair into a strictly-ordered integer key
* (`year * 12 + (month - 1)`) so range comparisons can be done with
* plain `<` / `>` instead of cascading conditionals. Pure math, no
* DateValue allocation.
*
* Used by month-grid range pickers to classify months as start / end /
* in-range across year boundaries.
*/
export function monthOrdinal(year: number, month: number): number {
return year * 12 + (month - 1);
}

@ -1,76 +1,21 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { CalendarDate } from '$libs/days';
import { MonthGrid } from '../month-grid';
import type { MonthValue, MonthPlaceholder } from '$soma/components/month-grid';
import type { MonthGridProviderSnippetProps, MonthGridSize } from '../month-grid';
/**
* Eidos `<DatePicker.MonthView>` — pure passthrough. Soma's
* `<DatePicker.MonthView>` owns the bridge from CalendarDate ↔
* MonthValue and the default MonthGrid composition. Eidos resolves
* `size` from the visual context and forwards it.
*/
import * as DatePicker from '$soma/components/date-picker';
import type { MonthGridSize } from '../month-grid';
import { getDatePickerVisualContext } from './context.svelte';
import type { HTMLAttributes } from 'svelte/elements';
// Month-grid view for `kind='month'`. Internally renders the standalone
// MonthGrid primitive, bridging its `{year, month}` value to the
// DatePickerProvider's CalendarDate value. Click on a month sets the
// picker's value to day=1 of that month and triggers handleDateSelect.
interface MonthViewProps extends HTMLAttributes<HTMLDivElement> {}
type Props = HTMLAttributes<HTMLDivElement>;
// View is a thin bridge that reads its state from the DatePickerProvider
// context. It accepts no extra props from consumers — the demo just
// renders `<DatePicker.MonthView />` without arguments. Bind $props()
// to silence Svelte's mandatory props declaration check.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _props: MonthViewProps = $props();
const props: Props = $props();
const provider = DatePickerProvider.require();
const visual = getDatePickerVisualContext();
const size = $derived((visual?.getSize() ?? 'md') as MonthGridSize);
const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
const monthValue = $derived<MonthValue | undefined>(
provider.opts.value.current
? { year: provider.opts.value.current.year, month: provider.opts.value.current.month }
: undefined
);
const monthPlaceholder = $derived<MonthPlaceholder>({ year: anchor.year });
function onValueChange(next: MonthValue | undefined): void {
if (!next) return;
const date = new CalendarDate(next.year, next.month, 1);
provider.opts.value.current = date;
provider.opts.placeholder.current = date;
provider.handleDateSelect();
}
function onPlaceholderChange(next: MonthPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
next.year,
placeholder.month,
placeholder.day
);
}
</script>
<MonthGrid
value={monthValue}
placeholder={monthPlaceholder}
{onValueChange}
{onPlaceholderChange}
{size}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Header>
<MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet}
</MonthGrid>
<DatePicker.MonthView {size} {...props} />

@ -1,75 +1,21 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { CalendarDate } from '$libs/days';
import { YearGrid } from '../year-grid';
import type { YearValue, YearPlaceholder } from '$soma/components/year-grid';
import type { YearGridProviderSnippetProps, YearGridSize } from '../year-grid';
/**
* Eidos `<DatePicker.YearView>` — pure passthrough. Soma's
* `<DatePicker.YearView>` owns the bridge from CalendarDate ↔
* YearValue and the default YearGrid composition. Eidos resolves
* `size` from the visual context and forwards it.
*/
import * as DatePicker from '$soma/components/date-picker';
import type { YearGridSize } from '../year-grid';
import { getDatePickerVisualContext } from './context.svelte';
import type { HTMLAttributes } from 'svelte/elements';
// Year-grid view for `kind='year'`. Internally renders the standalone
// YearGrid primitive, bridging its `{year}` value to the
// DatePickerProvider's CalendarDate value. Click on a year sets the
// picker's value to January 1 of that year and triggers handleDateSelect.
interface YearViewProps extends HTMLAttributes<HTMLDivElement> {}
type Props = HTMLAttributes<HTMLDivElement>;
// View is a thin bridge that reads its state from the DatePickerProvider
// context. It accepts no extra props from consumers — the demo just
// renders `<DatePicker.YearView />` without arguments. Bind $props()
// to silence Svelte's mandatory props declaration check.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _props: YearViewProps = $props();
const props: Props = $props();
const provider = DatePickerProvider.require();
const visual = getDatePickerVisualContext();
const size = $derived((visual?.getSize() ?? 'md') as YearGridSize);
const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
const yearValue = $derived<YearValue | undefined>(
provider.opts.value.current ? { year: provider.opts.value.current.year } : undefined
);
const yearPlaceholder = $derived<YearPlaceholder>({ year: anchor.year });
function onValueChange(next: YearValue | undefined): void {
if (!next) return;
// For kind='year' only the year matters — keep month=1, day=1.
const date = new CalendarDate(next.year, 1, 1);
provider.opts.value.current = date;
provider.opts.placeholder.current = date;
provider.handleDateSelect();
}
function onPlaceholderChange(next: YearPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
next.year,
placeholder.month,
placeholder.day
);
}
</script>
<YearGrid
value={yearValue}
placeholder={yearPlaceholder}
{onValueChange}
{onPlaceholderChange}
{size}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
>
{#snippet children({ years }: YearGridProviderSnippetProps)}
<YearGrid.Header>
<YearGrid.PrevButton>&lt;</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>&gt;</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/snippet}
</YearGrid>
<DatePicker.YearView {size} {...props} />

@ -1,124 +1,16 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { CalendarDate, DateFormatter } from '$libs/days';
/**
* Eidos `<DateRangePicker.MonthView>` — pure passthrough. Soma's
* `<DateRangePicker.MonthView>` owns the range selection state
* machine (empty/pending/complete) and the year stepper. Eidos just
* forwards props for chrome variations.
*/
import * as DateRangePicker from '$soma/components/date-range-picker';
import type { HTMLAttributes } from 'svelte/elements';
// Month-grid range view (kind='month'). 3×4 month grid for the
// placeholder's year + year nav. Same selection state machine as
// the year view; comparisons use a `year*12 + month` key so ranges
// can span multiple years.
interface MonthRangeViewProps extends HTMLAttributes<HTMLDivElement> {}
type Props = HTMLAttributes<HTMLDivElement>;
const { ...rest }: MonthRangeViewProps = $props();
const provider = DateRangePickerProvider.require();
const startVal = $derived(provider.opts.startValue.current);
const endVal = $derived(provider.opts.endValue.current);
const placeholder = $derived(provider.opts.placeholder.current);
const year = $derived(placeholder.year);
const months = $derived(Array.from({ length: 12 }, (_, i) => i + 1));
const formatter = $derived.by(() => {
const locale = provider.opts.locale.current || 'en-US';
return new DateFormatter(locale, { month: 'short' });
});
function monthLabel(month: number): string {
return formatter.format(new Date(year, month - 1, 1));
}
const startKey = $derived(startVal ? startVal.year * 12 + (startVal.month - 1) : undefined);
const endKey = $derived(endVal ? endVal.year * 12 + (endVal.month - 1) : undefined);
function keyFor(month: number): number {
return year * 12 + (month - 1);
}
function selectMonth(month: number) {
const s = startVal;
const e = endVal;
const isComplete = s !== undefined && e !== undefined;
if (!s || isComplete) {
const next = new CalendarDate(year, month, 1);
provider.opts.startValue.current = next;
provider.opts.endValue.current = undefined;
provider.opts.placeholder.current = next;
return;
}
// Pending end. Swap if the picked month is before start.
const sKey = s.year * 12 + (s.month - 1);
const k = keyFor(month);
const newDate = new CalendarDate(year, month, 1);
if (k < sKey) {
// Mark end as the last day of the original start month.
const endOfStartMonth = new CalendarDate(s.year, s.month, daysInMonth(s.year, s.month));
provider.opts.endValue.current = endOfStartMonth;
provider.opts.startValue.current = newDate;
} else {
const endOfMonth = new CalendarDate(year, month, daysInMonth(year, month));
provider.opts.endValue.current = endOfMonth;
}
provider.handleRangeSelect();
}
function stepYear(direction: -1 | 1) {
const p = placeholder;
provider.opts.placeholder.current = new CalendarDate(p.year + direction, p.month, p.day);
}
function classFor(month: number): { start: boolean; end: boolean; inRange: boolean } {
if (startKey === undefined) return { start: false, end: false, inRange: false };
const k = keyFor(month);
const start = k === startKey;
const end = endKey !== undefined && k === endKey;
const inRange =
endKey !== undefined && k > Math.min(startKey, endKey) && k < Math.max(startKey, endKey);
return { start, end, inRange };
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate();
}
const props: Props = $props();
</script>
<div data-date-range-picker-month-view role="grid" aria-label="Select month range" {...rest}>
<div data-date-range-picker-month-view-header>
<button
type="button"
data-date-range-picker-month-view-prev
aria-label="Previous year"
onclick={() => stepYear(-1)}
>
‹
</button>
<span data-date-range-picker-month-view-heading>{year}</span>
<button
type="button"
data-date-range-picker-month-view-next
aria-label="Next year"
onclick={() => stepYear(1)}
>
›
</button>
</div>
<div data-date-range-picker-month-view-grid>
{#each months as month (month)}
{@const c = classFor(month)}
<button
type="button"
data-date-range-picker-month-view-cell
data-range-start={c.start ? '' : undefined}
data-range-end={c.end ? '' : undefined}
data-in-range={c.inRange ? '' : undefined}
aria-pressed={c.start || c.end}
onclick={() => selectMonth(month)}
>
{monthLabel(month)}
</button>
{/each}
</div>
</div>
<DateRangePicker.MonthView {...props} />

@ -1,110 +1,16 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { CalendarDate } from '$libs/days';
/**
* Eidos `<DateRangePicker.YearView>` — pure passthrough. Soma's
* `<DateRangePicker.YearView>` owns the range selection state
* machine (empty/pending/complete) and the decade stepper. Eidos
* just forwards props for chrome variations.
*/
import * as DateRangePicker from '$soma/components/date-range-picker';
import type { HTMLAttributes } from 'svelte/elements';
// Year-grid range view (kind='year'). 3×4 decade grid + decade nav.
//
// Selection state machine (mirrors the day-grid range selection):
// - Empty (neither start nor end set): click sets startValue (and
// clears endValue).
// - Pending (start set, no end): click sets endValue. If the clicked
// year is before startValue, swap them so start <= end.
// - Complete (both set): click resets to a fresh start + clears end.
//
// Year endpoints normalise to Jan 1 for start, Dec 31 for end.
interface YearRangeViewProps extends HTMLAttributes<HTMLDivElement> {}
type Props = HTMLAttributes<HTMLDivElement>;
const { ...rest }: YearRangeViewProps = $props();
const provider = DateRangePickerProvider.require();
const startVal = $derived(provider.opts.startValue.current);
const endVal = $derived(provider.opts.endValue.current);
const placeholder = $derived(provider.opts.placeholder.current);
// Decade window: anchor on startValue or placeholder, snap to decade.
const anchorYear = $derived(startVal?.year ?? placeholder.year);
const decadeStart = $derived(anchorYear - (anchorYear % 10));
const years = $derived(Array.from({ length: 12 }, (_, i) => decadeStart + i));
function selectYear(year: number) {
const s = startVal;
const e = endVal;
const isComplete = s !== undefined && e !== undefined;
if (!s || isComplete) {
// Fresh start.
provider.opts.startValue.current = new CalendarDate(year, 1, 1);
provider.opts.endValue.current = undefined;
provider.opts.placeholder.current = new CalendarDate(year, 1, 1);
return;
}
// Pending end: set the second endpoint, swap if reversed.
const startYear = s.year;
if (year < startYear) {
provider.opts.endValue.current = new CalendarDate(startYear, 12, 31);
provider.opts.startValue.current = new CalendarDate(year, 1, 1);
} else {
provider.opts.endValue.current = new CalendarDate(year, 12, 31);
}
provider.handleRangeSelect();
}
function stepDecade(direction: -1 | 1) {
const p = placeholder;
provider.opts.placeholder.current = new CalendarDate(p.year + direction * 10, p.month, p.day);
}
function classFor(year: number): { start: boolean; end: boolean; inRange: boolean } {
const sy = startVal?.year;
const ey = endVal?.year;
if (sy === undefined) return { start: false, end: false, inRange: false };
const start = year === sy;
const end = ey !== undefined && year === ey;
const inRange =
ey !== undefined && year > Math.min(sy, ey) && year < Math.max(sy, ey);
return { start, end, inRange };
}
const props: Props = $props();
</script>
<div data-date-range-picker-year-view role="grid" aria-label="Select year range" {...rest}>
<div data-date-range-picker-year-view-header>
<button
type="button"
data-date-range-picker-year-view-prev
aria-label="Previous decade"
onclick={() => stepDecade(-1)}
>
‹
</button>
<span data-date-range-picker-year-view-heading>
{decadeStart} – {decadeStart + 11}
</span>
<button
type="button"
data-date-range-picker-year-view-next
aria-label="Next decade"
onclick={() => stepDecade(1)}
>
›
</button>
</div>
<div data-date-range-picker-year-view-grid>
{#each years as year (year)}
{@const c = classFor(year)}
<button
type="button"
data-date-range-picker-year-view-cell
data-range-start={c.start ? '' : undefined}
data-range-end={c.end ? '' : undefined}
data-in-range={c.inRange ? '' : undefined}
aria-pressed={c.start || c.end}
onclick={() => selectYear(year)}
>
{year}
</button>
{/each}
</div>
</div>
<DateRangePicker.YearView {...props} />

@ -232,6 +232,30 @@ export const datePickerMorfo = {
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
},
{
// Month-view bridge for `kind='month'`. Owns the bridge from
// CalendarDate ↔ MonthValue + handleDateSelect. Visual chrome
// (header/grid composition) is the wrapped MonthGrid's job.
name: 'MonthView',
kebab: 'month-view',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Year-view bridge for `kind='year'`. Symmetric to MonthView.
name: 'YearView',
kebab: 'year-view',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
// Footer / Clear / Cancel / Close — declared by the shared
// `picker-shell` (Eidos `<PickerShell.Footer/Clear/Cancel/Close>`).

@ -237,6 +237,45 @@ export const dateRangePickerMorfo = {
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
},
{
// Month-view range bridge for `kind='month'`. Owns the range
// selection state machine (empty / pending / complete) + year
// stepper. Visual chrome painted by eidos via the snippet.
name: 'MonthView',
kebab: 'month-view',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'grid',
optional: true,
data: [],
aria: [
{
attr: 'aria-label',
value: v.literal('Select month range'),
severity: 'optional'
}
]
},
{
// Year-view range bridge for `kind='year'`. Symmetric to the
// MonthView but with decade pagination.
name: 'YearView',
kebab: 'year-view',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'grid',
optional: true,
data: [],
aria: [
{
attr: 'aria-label',
value: v.literal('Select year range'),
severity: 'optional'
}
]
}
// Footer / Clear / Cancel / Close — chrome shared via picker-shell;
// not declared here. Eidos `<DateRangePicker.Footer/Clear/Cancel/

@ -0,0 +1,95 @@
<script lang="ts">
/**
* Soma `<DatePicker.MonthView>` — month-grid view for `kind='month'`.
*
* Bridges DatePicker's CalendarDate state to MonthGrid's `{year, month}`
* value: reads the picker's current value/placeholder/locale/disabled/
* readonly, exposes them via MonthGrid, and on month-select converts
* back to a CalendarDate (day=1) + fires `handleDateSelect()` so the
* picker's commit pipeline runs.
*
* Internal composition: renders the default `<` Heading `>` header +
* 3x4 grid. Consumers can override the body via the `children` snippet
* (receives the live `months` array from MonthGrid).
*/
import type { Snippet } from 'svelte';
import { CalendarDate } from '$libs/days';
import { DatePickerProvider } from '../date-picker-provider.svelte';
import * as MonthGrid from '../../month-grid/exports';
import type {
MonthValue,
MonthPlaceholder,
MonthGridProviderSnippetProps,
MonthGridProps
} from '../../month-grid/types';
type Props = Omit<
MonthGridProps,
'value' | 'placeholder' | 'onValueChange' | 'onPlaceholderChange' | 'children'
> & {
children?: Snippet<[MonthGridProviderSnippetProps]>;
};
let { children, ...rest }: Props = $props();
const provider = DatePickerProvider.require();
const anchor = $derived(
provider.opts.value.current ?? provider.opts.placeholder.current
);
const monthValue = $derived<MonthValue | undefined>(
provider.opts.value.current
? {
year: provider.opts.value.current.year,
month: provider.opts.value.current.month
}
: undefined
);
const monthPlaceholder = $derived<MonthPlaceholder>({ year: anchor.year });
function onValueChange(next: MonthValue | undefined): void {
if (!next) return;
const date = new CalendarDate(next.year, next.month, 1);
provider.opts.value.current = date;
provider.opts.placeholder.current = date;
provider.handleDateSelect();
}
function onPlaceholderChange(next: MonthPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
next.year,
placeholder.month,
placeholder.day
);
}
</script>
<MonthGrid.Provider
value={monthValue}
placeholder={monthPlaceholder}
{onValueChange}
{onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
{...rest}
>
{#snippet children(snippetProps: MonthGridProviderSnippetProps)}
{#if children}
{@render children(snippetProps)}
{:else}
<MonthGrid.Header>
<MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each snippetProps.months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/if}
{/snippet}
</MonthGrid.Provider>

@ -0,0 +1,93 @@
<script lang="ts">
/**
* Soma `<DatePicker.YearView>` — year-grid view for `kind='year'`.
*
* Bridges DatePicker's CalendarDate state to YearGrid's `{year}`
* value: reads the picker's current value/placeholder/locale/disabled/
* readonly, exposes them via YearGrid, and on year-select converts
* back to a CalendarDate (Jan 1) + fires `handleDateSelect()` so the
* picker's commit pipeline runs.
*
* Internal composition: renders the default `<` Heading `>` header +
* decade grid. Consumers can override the body via the `children`
* snippet (receives the live `years` array from YearGrid).
*/
import type { Snippet } from 'svelte';
import { CalendarDate } from '$libs/days';
import { DatePickerProvider } from '../date-picker-provider.svelte';
import * as YearGrid from '../../year-grid/exports';
import type {
YearValue,
YearPlaceholder,
YearGridProviderSnippetProps,
YearGridProps
} from '../../year-grid/types';
type Props = Omit<
YearGridProps,
'value' | 'placeholder' | 'onValueChange' | 'onPlaceholderChange' | 'children'
> & {
children?: Snippet<[YearGridProviderSnippetProps]>;
};
let { children, ...rest }: Props = $props();
const provider = DatePickerProvider.require();
const anchor = $derived(
provider.opts.value.current ?? provider.opts.placeholder.current
);
const yearValue = $derived<YearValue | undefined>(
provider.opts.value.current
? { year: provider.opts.value.current.year }
: undefined
);
const yearPlaceholder = $derived<YearPlaceholder>({ year: anchor.year });
function onValueChange(next: YearValue | undefined): void {
if (!next) return;
// For kind='year' only the year matters — keep month=1, day=1.
const date = new CalendarDate(next.year, 1, 1);
provider.opts.value.current = date;
provider.opts.placeholder.current = date;
provider.handleDateSelect();
}
function onPlaceholderChange(next: YearPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
next.year,
placeholder.month,
placeholder.day
);
}
</script>
<YearGrid.Provider
value={yearValue}
placeholder={yearPlaceholder}
{onValueChange}
{onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
{...rest}
>
{#snippet children(snippetProps: YearGridProviderSnippetProps)}
{#if children}
{@render children(snippetProps)}
{:else}
<YearGrid.Header>
<YearGrid.PrevButton>&lt;</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>&gt;</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each snippetProps.years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/if}
{/snippet}
</YearGrid.Provider>

@ -2,6 +2,8 @@
export { default as Provider } from './components/date-picker.svelte';
export { default as Trigger } from './components/date-picker-trigger.svelte';
export { default as Calendar } from './components/date-picker-calendar.svelte';
export { default as MonthView } from './components/date-picker-month-view.svelte';
export { default as YearView } from './components/date-picker-year-view.svelte';
// Provider class (action helpers: commit / cancel / clear / handleDateSelect)
export { DatePickerProvider, datePickerAttrs } from './internals';

@ -0,0 +1,143 @@
<script lang="ts">
/**
* Soma `<DateRangePicker.MonthView>` — month-grid range view for
* `kind='month'`. Owns the range selection state machine and the
* year navigation; eidos paints chrome via the `children` snippet.
*
* Range selection state machine (mirrors the day-grid range flow):
* - Empty (neither start nor end): click sets startValue, clears endValue.
* - Pending (start set, no end): click sets endValue. If the clicked
* month is before startValue, swap so start ≤ end.
* - Complete (both set): click resets to a fresh start + clears end.
*
* Month endpoints normalise to day=1 for start, last-day-of-month for end.
* Comparisons use `monthOrdinal` from `$libs/days` so ranges spanning
* multiple years sort correctly.
*/
import type { Snippet } from 'svelte';
import { CalendarDate, endOfMonth, monthOrdinal } from '$libs/days';
import { DateRangePickerProvider } from '../date-range-picker-provider.svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface MonthRangeClassification {
start: boolean;
end: boolean;
inRange: boolean;
}
interface DateRangePickerMonthViewSnippetProps {
year: number;
months: readonly number[];
classify: (month: number) => MonthRangeClassification;
selectMonth: (month: number) => void;
stepYear: (direction: -1 | 1) => void;
}
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Override the default chrome with a custom render. */
children?: Snippet<[DateRangePickerMonthViewSnippetProps]>;
};
let { children, ...rest }: Props = $props();
const provider = DateRangePickerProvider.require();
const startVal = $derived(provider.opts.startValue.current);
const endVal = $derived(provider.opts.endValue.current);
const placeholder = $derived(provider.opts.placeholder.current);
const year = $derived(placeholder.year);
const months = $derived(Array.from({ length: 12 }, (_, i) => i + 1));
const startKey = $derived(
startVal ? monthOrdinal(startVal.year, startVal.month) : undefined
);
const endKey = $derived(endVal ? monthOrdinal(endVal.year, endVal.month) : undefined);
function classify(month: number): MonthRangeClassification {
if (startKey === undefined) return { start: false, end: false, inRange: false };
const k = monthOrdinal(year, month);
const start = k === startKey;
const end = endKey !== undefined && k === endKey;
const inRange =
endKey !== undefined &&
k > Math.min(startKey, endKey) &&
k < Math.max(startKey, endKey);
return { start, end, inRange };
}
function selectMonth(month: number): void {
const s = startVal;
const e = endVal;
const isComplete = s !== undefined && e !== undefined;
if (!s || isComplete) {
const next = new CalendarDate(year, month, 1);
provider.opts.startValue.current = next;
provider.opts.endValue.current = undefined;
provider.opts.placeholder.current = next;
return;
}
// Pending end. Swap if the picked month is before start.
const sKey = monthOrdinal(s.year, s.month);
const k = monthOrdinal(year, month);
const newStartOfMonth = new CalendarDate(year, month, 1);
if (k < sKey) {
// Mark end as the last day of the original start month.
provider.opts.endValue.current = endOfMonth(new CalendarDate(s.year, s.month, 1));
provider.opts.startValue.current = newStartOfMonth;
} else {
provider.opts.endValue.current = endOfMonth(new CalendarDate(year, month, 1));
}
provider.handleRangeSelect();
}
function stepYear(direction: -1 | 1): void {
const p = placeholder;
provider.opts.placeholder.current = new CalendarDate(p.year + direction, p.month, p.day);
}
</script>
<div
data-date-range-picker-month-view
role="grid"
aria-label="Select month range"
{...rest}
>
{#if children}
{@render children({ year, months, classify, selectMonth, stepYear })}
{:else}
<div data-date-range-picker-month-view-header>
<button
type="button"
data-date-range-picker-month-view-prev
aria-label="Previous year"
onclick={() => stepYear(-1)}>‹</button
>
<span data-date-range-picker-month-view-heading>{year}</span>
<button
type="button"
data-date-range-picker-month-view-next
aria-label="Next year"
onclick={() => stepYear(1)}>›</button
>
</div>
<div data-date-range-picker-month-view-grid>
{#each months as month (month)}
{@const c = classify(month)}
<button
type="button"
data-date-range-picker-month-view-cell
data-range-start={c.start ? '' : undefined}
data-range-end={c.end ? '' : undefined}
data-in-range={c.inRange ? '' : undefined}
aria-pressed={c.start || c.end}
onclick={() => selectMonth(month)}
>
{month}
</button>
{/each}
</div>
{/if}
</div>

@ -0,0 +1,141 @@
<script lang="ts">
/**
* Soma `<DateRangePicker.YearView>` — year-grid range view for
* `kind='year'`. Owns the range selection state machine and the
* decade navigation; eidos paints chrome via the `children` snippet.
*
* Selection state machine (mirrors the day-grid + month-grid range
* flow):
* - Empty: click sets startValue (Jan 1 of year), clears endValue.
* - Pending: click sets endValue (Dec 31). Swap if picked year < start.
* - Complete: click resets to a fresh start.
*
* Decade window anchored on startValue OR placeholder; snap via
* `startOfDecadeYear()` from `$libs/days`. Window length is 12 (a
* 3×4 grid) which extends two years past the decade.
*/
import type { Snippet } from 'svelte';
import { CalendarDate, startOfDecadeYear } from '$libs/days';
import { DateRangePickerProvider } from '../date-range-picker-provider.svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface YearRangeClassification {
start: boolean;
end: boolean;
inRange: boolean;
}
interface DateRangePickerYearViewSnippetProps {
decadeStart: number;
years: readonly number[];
classify: (year: number) => YearRangeClassification;
selectYear: (year: number) => void;
stepDecade: (direction: -1 | 1) => void;
}
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Override the default chrome with a custom render. */
children?: Snippet<[DateRangePickerYearViewSnippetProps]>;
};
let { children, ...rest }: Props = $props();
const provider = DateRangePickerProvider.require();
const startVal = $derived(provider.opts.startValue.current);
const endVal = $derived(provider.opts.endValue.current);
const placeholder = $derived(provider.opts.placeholder.current);
const anchorYear = $derived(startVal?.year ?? placeholder.year);
const decadeStart = $derived(startOfDecadeYear(anchorYear));
const years = $derived(Array.from({ length: 12 }, (_, i) => decadeStart + i));
function classify(yearN: number): YearRangeClassification {
const sy = startVal?.year;
const ey = endVal?.year;
if (sy === undefined) return { start: false, end: false, inRange: false };
const start = yearN === sy;
const end = ey !== undefined && yearN === ey;
const inRange =
ey !== undefined && yearN > Math.min(sy, ey) && yearN < Math.max(sy, ey);
return { start, end, inRange };
}
function selectYear(yearN: number): void {
const s = startVal;
const e = endVal;
const isComplete = s !== undefined && e !== undefined;
if (!s || isComplete) {
// Fresh start.
provider.opts.startValue.current = new CalendarDate(yearN, 1, 1);
provider.opts.endValue.current = undefined;
provider.opts.placeholder.current = new CalendarDate(yearN, 1, 1);
return;
}
// Pending end: set the second endpoint, swap if reversed.
const startYear = s.year;
if (yearN < startYear) {
provider.opts.endValue.current = new CalendarDate(startYear, 12, 31);
provider.opts.startValue.current = new CalendarDate(yearN, 1, 1);
} else {
provider.opts.endValue.current = new CalendarDate(yearN, 12, 31);
}
provider.handleRangeSelect();
}
function stepDecade(direction: -1 | 1): void {
const p = placeholder;
provider.opts.placeholder.current = new CalendarDate(
p.year + direction * 10,
p.month,
p.day
);
}
</script>
<div
data-date-range-picker-year-view
role="grid"
aria-label="Select year range"
{...rest}
>
{#if children}
{@render children({ decadeStart, years, classify, selectYear, stepDecade })}
{:else}
<div data-date-range-picker-year-view-header>
<button
type="button"
data-date-range-picker-year-view-prev
aria-label="Previous decade"
onclick={() => stepDecade(-1)}>‹</button
>
<span data-date-range-picker-year-view-heading>
{decadeStart} – {decadeStart + 11}
</span>
<button
type="button"
data-date-range-picker-year-view-next
aria-label="Next decade"
onclick={() => stepDecade(1)}>›</button
>
</div>
<div data-date-range-picker-year-view-grid>
{#each years as yearN (yearN)}
{@const c = classify(yearN)}
<button
type="button"
data-date-range-picker-year-view-cell
data-range-start={c.start ? '' : undefined}
data-range-end={c.end ? '' : undefined}
data-in-range={c.inRange ? '' : undefined}
aria-pressed={c.start || c.end}
onclick={() => selectYear(yearN)}
>
{yearN}
</button>
{/each}
</div>
{/if}
</div>

@ -2,6 +2,8 @@
export { default as Provider } from './components/date-range-picker.svelte';
export { default as Trigger } from './components/date-range-picker-trigger.svelte';
export { default as Calendar } from './components/date-range-picker-calendar.svelte';
export { default as MonthView } from './components/date-range-picker-month-view.svelte';
export { default as YearView } from './components/date-range-picker-year-view.svelte';
// Provider class — required by consumers that wire footer actions
// (Clear / Cancel / Save) inside the calendar tree via `.require()`.

Loading…
Cancel
Save

Powered by TurnKey Linux.