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">
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';
// Month-grid view for `kind='month'`. Renders a 3×4 grid of months for
// the placeholder's year. 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.
// 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> {}
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 anchor = $derived(
provider.opts.value.current ?? provider.opts.placeholder.current
);
const year = $derived(anchor.year);
const months = $derived(Array.from({ length: 12 }, (_, i) => i + 1));
const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
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 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 selectMonth(month: number) {
const next = new CalendarDate(year, month, 1);
provider.opts.value.current = next;
provider.opts.placeholder.current = next;
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 stepYear(direction: -1 | 1) {
function onPlaceholderChange(next: MonthPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction,
next.year,
placeholder.month,
placeholder.day
);
}
function isSelected(month: number): boolean {
const v = provider.opts.value.current;
return v?.year === year && v?.month === month;
}
</script>
<div
data-date-picker-month-view
role="grid"
aria-label="Select month"
{...rest}
<MonthGrid
value={monthValue}
placeholder={monthPlaceholder}
{onValueChange}
{onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
>
<div data-date-picker-month-view-header>
<button
type="button"
data-date-picker-month-view-prev
aria-label="Previous year"
onclick={() => stepYear(-1)}
>
‹
</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>
{#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)}
<button
type="button"
data-date-picker-month-view-cell
data-selected={isSelected(month) ? '' : undefined}
aria-pressed={isSelected(month)}
onclick={() => selectMonth(month)}
>
{monthLabel(month)}
</button>
<MonthGrid.Cell {month} />
{/each}
</div>
</div>
</MonthGrid.Grid>
{/snippet}
</MonthGrid>

@ -1,97 +1,71 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
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';
// Year-grid view for `kind='year'`. Renders a 3×4 decade grid centred
// on the placeholder year. Click on a year sets the picker's value to
// January 1 of that year and triggers handleDateSelect (closes the
// popover when closeOnDateSelect is true and mode is inline).
//
// 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.
// 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> {}
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();
// Anchor year: the value's year if any, otherwise the placeholder's.
const anchorYear = $derived(
provider.opts.value.current?.year ?? provider.opts.placeholder.current.year
);
const anchor = $derived(provider.opts.value.current ?? provider.opts.placeholder.current);
// Decade window: 12 years starting at the previous decade boundary.
// E.g. anchor=2026 → start=2020 → years 2020..2031.
const decadeStart = $derived(anchorYear - (anchorYear % 10));
const years = $derived(Array.from({ length: 12 }, (_, i) => decadeStart + i));
const yearValue = $derived<YearValue | undefined>(
provider.opts.value.current ? { year: provider.opts.value.current.year } : undefined
);
const yearPlaceholder = $derived<YearPlaceholder>({ year: anchor.year });
function selectYear(year: number) {
const current = provider.opts.value.current ?? provider.opts.placeholder.current;
// For kind='year', only the year matters — keep month=1, day=1.
// Down-stream consumers see the value's year via `.year`.
const next = new CalendarDate(year, 1, 1);
provider.opts.value.current = next;
provider.opts.placeholder.current = next;
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 stepDecade(direction: -1 | 1) {
function onPlaceholderChange(next: YearPlaceholder): void {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction * 10,
next.year,
placeholder.month,
placeholder.day
);
}
function isSelected(year: number): boolean {
return provider.opts.value.current?.year === year;
}
</script>
<div
data-date-picker-year-view
role="grid"
aria-label="Select year"
{...rest}
<YearGrid
value={yearValue}
placeholder={yearPlaceholder}
{onValueChange}
{onPlaceholderChange}
locale={provider.opts.locale.current}
disabled={provider.opts.disabled.current}
readonly={provider.opts.readonly.current}
>
<div data-date-picker-year-view-header>
<button
type="button"
data-date-picker-year-view-prev
aria-label="Previous decade"
onclick={() => stepDecade(-1)}
>
‹
</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>
{#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)}
<button
type="button"
data-date-picker-year-view-cell
data-selected={isSelected(year) ? '' : undefined}
aria-pressed={isSelected(year)}
onclick={() => selectYear(year)}
>
{year}
</button>
<YearGrid.Cell {year} />
{/each}
</div>
</div>
</YearGrid.Grid>
{/snippet}
</YearGrid>

@ -195,97 +195,12 @@
/* ── Year-view + Month-view (kind='year' / 'month') ──────────────────────── */
[data-date-picker-year-view],
[data-date-picker-month-view] {
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);
}
/* DatePicker.MonthView and DatePicker.YearView now delegate to the
standalone MonthGrid / YearGrid primitives, so their visuals come from
`month-grid.css` and `year-grid.css`. No local rules needed. */
@media (prefers-reduced-motion: reduce) {
[data-date-picker-trigger] {
transition: none;
}
[data-date-picker-year-view-cell],
[data-date-picker-month-view-cell] {
transition: none;
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.