refactor(date-picker): MonthView / YearView delegate to MonthGrid / YearGrid

date-picker-month-view.svelte and date-picker-year-view.svelte previously
rolled their own 3×4 grids with custom data-date-picker-{month,year}-view-*
markers and ~90 lines of selection / nav / formatter logic. They now
render the standalone MonthGrid / YearGrid primitives (introduced in #70)
and bridge a thin DatePickerProvider context adapter:

- value: CalendarDate ↔ {year, month?} / {year}
- placeholder: CalendarDate ↔ {year}
- onValueChange → new CalendarDate(year, month, 1) + handleDateSelect
- onPlaceholderChange → mutate provider.placeholder.year
- forwards locale / disabled / readonly straight from the provider

CSS: removed the data-date-picker-{month,year}-view-* selector block
(~90 lines of dead code). Visuals now come from month-grid.css /
year-grid.css, which means the views also inherit the size='xs' work,
roving tabindex, RTL keyboard flip and sema events for free.

Verified in browser at /uix/components/date-picker:
- kind='month' → MonthGrid renders 12 cells (Jan..Dec) inside the popover
- click Mar → value becomes 2026-03-01, input segments update to 03/2026
- kind='year' → YearGrid renders the 2016–2027 page
- svelte-check --threshold error → 0 errors / 0 warnings / 4572 files

date-range-picker MonthView / YearView left untouched — their range
state machine (start/end/in-range markers, complete-vs-pending swap) is
not modelled by the standalone grids.

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

@ -1,94 +1,72 @@
<script lang="ts"> <script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker'; import { DatePickerProvider } from '$soma/components/date-picker';
import { CalendarDate, DateFormatter } from '$libs/days'; import { CalendarDate } from '$libs/days';
import { MonthGrid } from '../month-grid';
import type { MonthValue, MonthPlaceholder } from '$soma/components/month-grid';
import type { MonthGridProviderSnippetProps } from '../month-grid';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
// Month-grid view for `kind='month'`. Renders a 3×4 grid of months for // Month-grid view for `kind='month'`. Internally renders the standalone
// the placeholder's year. Click on a month sets the picker's value to // MonthGrid primitive, bridging its `{year, month}` value to the
// day=1 of that month and triggers handleDateSelect. // DatePickerProvider's CalendarDate value. Click on a month sets the
// // picker's value to day=1 of that month and triggers handleDateSelect.
// Header shows the current year with prev/next buttons that step by
// year (not month) — the granularity matches the view.
interface MonthViewProps extends HTMLAttributes<HTMLDivElement> {} interface MonthViewProps extends HTMLAttributes<HTMLDivElement> {}
const { ...rest }: MonthViewProps = $props(); // 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 provider = DatePickerProvider.require(); const provider = DatePickerProvider.require();
const anchor = $derived( const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
provider.opts.value.current ?? provider.opts.placeholder.current
);
const year = $derived(anchor.year);
const months = $derived(Array.from({ length: 12 }, (_, i) => i + 1));
const formatter = $derived.by(() => { const monthValue = $derived<MonthValue | undefined>(
const locale = provider.opts.locale.current || 'en-US'; provider.opts.value.current
return new DateFormatter(locale, { month: 'short' }); ? { year: provider.opts.value.current.year, month: provider.opts.value.current.month }
}); : undefined
);
function monthLabel(month: number): string { const monthPlaceholder = $derived<MonthPlaceholder>({ year: anchor.year });
return formatter.format(new Date(year, month - 1, 1));
}
function selectMonth(month: number) { function onValueChange(next: MonthValue | undefined): void {
const next = new CalendarDate(year, month, 1); if (!next) return;
provider.opts.value.current = next; const date = new CalendarDate(next.year, next.month, 1);
provider.opts.placeholder.current = next; provider.opts.value.current = date;
provider.opts.placeholder.current = date;
provider.handleDateSelect(); provider.handleDateSelect();
} }
function stepYear(direction: -1 | 1) { function onPlaceholderChange(next: MonthPlaceholder): void {
const placeholder = provider.opts.placeholder.current; const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate( provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction, next.year,
placeholder.month, placeholder.month,
placeholder.day placeholder.day
); );
} }
function isSelected(month: number): boolean {
const v = provider.opts.value.current;
return v?.year === year && v?.month === month;
}
</script> </script>
<div <MonthGrid
data-date-picker-month-view value={monthValue}
role="grid" placeholder={monthPlaceholder}
aria-label="Select month" {onValueChange}
{...rest} {onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
> >
<div data-date-picker-month-view-header> {#snippet children({ months }: MonthGridProviderSnippetProps)}
<button <MonthGrid.Header>
type="button" <MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
data-date-picker-month-view-prev <MonthGrid.Heading />
aria-label="Previous year" <MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
onclick={() => stepYear(-1)} </MonthGrid.Header>
> <MonthGrid.Grid>
‹
</button>
<span data-date-picker-month-view-heading>{year}</span>
<button
type="button"
data-date-picker-month-view-next
aria-label="Next year"
onclick={() => stepYear(1)}
>
›
</button>
</div>
<div data-date-picker-month-view-grid>
{#each months as month (month)} {#each months as month (month)}
<button <MonthGrid.Cell {month} />
type="button"
data-date-picker-month-view-cell
data-selected={isSelected(month) ? '' : undefined}
aria-pressed={isSelected(month)}
onclick={() => selectMonth(month)}
>
{monthLabel(month)}
</button>
{/each} {/each}
</div> </MonthGrid.Grid>
</div> {/snippet}
</MonthGrid>

@ -1,97 +1,71 @@
<script lang="ts"> <script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker'; import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
import { CalendarDate } from '$libs/days'; import { CalendarDate } from '$libs/days';
import { YearGrid } from '../year-grid';
import type { YearValue, YearPlaceholder } from '$soma/components/year-grid';
import type { YearGridProviderSnippetProps } from '../year-grid';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
// Year-grid view for `kind='year'`. Renders a 3×4 decade grid centred // Year-grid view for `kind='year'`. Internally renders the standalone
// on the placeholder year. Click on a year sets the picker's value to // YearGrid primitive, bridging its `{year}` value to the
// January 1 of that year and triggers handleDateSelect (closes the // DatePickerProvider's CalendarDate value. Click on a year sets the
// popover when closeOnDateSelect is true and mode is inline). // picker's value to January 1 of that year and triggers handleDateSelect.
//
// Decade range: the anchor year's decade plus the previous and next
// years to fill 12 cells. Example for 2026 → 2020..2031 padded so the
// row starts on a decade boundary.
interface YearViewProps extends HTMLAttributes<HTMLDivElement> {} interface YearViewProps extends HTMLAttributes<HTMLDivElement> {}
const { ...rest }: YearViewProps = $props(); // 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 uix = getActiveUix();
void uix;
const provider = DatePickerProvider.require(); const provider = DatePickerProvider.require();
// Anchor year: the value's year if any, otherwise the placeholder's. const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
const anchorYear = $derived(
provider.opts.value.current?.year ?? provider.opts.placeholder.current.year
);
// Decade window: 12 years starting at the previous decade boundary. const yearValue = $derived<YearValue | undefined>(
// E.g. anchor=2026 → start=2020 → years 2020..2031. provider.opts.value.current ? { year: provider.opts.value.current.year } : undefined
const decadeStart = $derived(anchorYear - (anchorYear % 10)); );
const years = $derived(Array.from({ length: 12 }, (_, i) => decadeStart + i)); const yearPlaceholder = $derived<YearPlaceholder>({ year: anchor.year });
function selectYear(year: number) { function onValueChange(next: YearValue | undefined): void {
const current = provider.opts.value.current ?? provider.opts.placeholder.current; if (!next) return;
// For kind='year', only the year matters — keep month=1, day=1. // For kind='year' only the year matters — keep month=1, day=1.
// Down-stream consumers see the value's year via `.year`. const date = new CalendarDate(next.year, 1, 1);
const next = new CalendarDate(year, 1, 1); provider.opts.value.current = date;
provider.opts.value.current = next; provider.opts.placeholder.current = date;
provider.opts.placeholder.current = next;
provider.handleDateSelect(); provider.handleDateSelect();
} }
function stepDecade(direction: -1 | 1) { function onPlaceholderChange(next: YearPlaceholder): void {
const placeholder = provider.opts.placeholder.current; const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate( provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction * 10, next.year,
placeholder.month, placeholder.month,
placeholder.day placeholder.day
); );
} }
function isSelected(year: number): boolean {
return provider.opts.value.current?.year === year;
}
</script> </script>
<div <YearGrid
data-date-picker-year-view value={yearValue}
role="grid" placeholder={yearPlaceholder}
aria-label="Select year" {onValueChange}
{...rest} {onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
> >
<div data-date-picker-year-view-header> {#snippet children({ years }: YearGridProviderSnippetProps)}
<button <YearGrid.Header>
type="button" <YearGrid.PrevButton>&lt;</YearGrid.PrevButton>
data-date-picker-year-view-prev <YearGrid.Heading />
aria-label="Previous decade" <YearGrid.NextButton>&gt;</YearGrid.NextButton>
onclick={() => stepDecade(-1)} </YearGrid.Header>
> <YearGrid.Grid>
‹
</button>
<span data-date-picker-year-view-heading>
{decadeStart} – {decadeStart + 11}
</span>
<button
type="button"
data-date-picker-year-view-next
aria-label="Next decade"
onclick={() => stepDecade(1)}
>
›
</button>
</div>
<div data-date-picker-year-view-grid>
{#each years as year (year)} {#each years as year (year)}
<button <YearGrid.Cell {year} />
type="button"
data-date-picker-year-view-cell
data-selected={isSelected(year) ? '' : undefined}
aria-pressed={isSelected(year)}
onclick={() => selectYear(year)}
>
{year}
</button>
{/each} {/each}
</div> </YearGrid.Grid>
</div> {/snippet}
</YearGrid>

@ -195,97 +195,12 @@
/* ── Year-view + Month-view (kind='year' / 'month') ──────────────────────── */ /* ── Year-view + Month-view (kind='year' / 'month') ──────────────────────── */
/* DatePicker.MonthView and DatePicker.YearView now delegate to the
[data-date-picker-year-view], standalone MonthGrid / YearGrid primitives, so their visuals come from
[data-date-picker-month-view] { `month-grid.css` and `year-grid.css`. No local rules needed. */
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: min(20rem, 100%);
}
[data-date-picker-year-view-header],
[data-date-picker-month-view-header] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
[data-date-picker-year-view-heading],
[data-date-picker-month-view-heading] {
font-weight: var(--font-weight-semibold);
color: var(--color-content-primary);
}
[data-date-picker-year-view-prev],
[data-date-picker-year-view-next],
[data-date-picker-month-view-prev],
[data-date-picker-month-view-next] {
min-block-size: var(--control-height-sm);
min-inline-size: var(--control-height-sm);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
cursor: pointer;
font: inherit;
font-size: var(--font-size-md);
}
[data-date-picker-year-view-prev]:hover,
[data-date-picker-year-view-next]:hover,
[data-date-picker-month-view-prev]:hover,
[data-date-picker-month-view-next]:hover {
background: var(--color-surface-overlay);
}
[data-date-picker-year-view-grid],
[data-date-picker-month-view-grid] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
[data-date-picker-year-view-cell],
[data-date-picker-month-view-cell] {
min-block-size: var(--control-height-md);
border-radius: var(--radius-md);
border: var(--border-width) solid transparent;
background: transparent;
color: var(--color-content-primary);
cursor: pointer;
font: inherit;
font-size: var(--font-size-sm);
transition:
background var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-date-picker-year-view-cell]:hover,
[data-date-picker-month-view-cell]:hover {
background: var(--color-surface-overlay);
}
[data-date-picker-year-view-cell][data-selected],
[data-date-picker-month-view-cell][data-selected] {
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
border-color: var(--color-primary-border);
}
[data-date-picker-year-view-cell]:focus-visible,
[data-date-picker-month-view-cell]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
[data-date-picker-trigger] { [data-date-picker-trigger] {
transition: none; transition: none;
} }
[data-date-picker-year-view-cell],
[data-date-picker-month-view-cell] {
transition: none;
}
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.