date-range-picker: YearRangeView + MonthRangeView (kind grids)

Completes the kind contract for the range picker. The popover used to
render the day-grid range calendar regardless of kind; now it switches
to a year-grid or month-grid view that handles range selection on the
appropriate granularity.

New Eidos parts:
- <DateRangePicker.YearView>: 3×4 decade grid with prev/next decade
  buttons. Selection state machine (mirrors the day-grid range
  selection):
  * empty → click sets startValue (and clears endValue)
  * pending (start set, no end) → click sets endValue. Swap if the
    clicked year is before the existing start.
  * complete (both set) → click resets to a fresh start + clears end.
  Year endpoints normalise to Jan 1 (start) / Dec 31 (end).
- <DateRangePicker.MonthView>: 3×4 month grid for the placeholder's
  year, with prev/next year navigation. Same state machine using
  `year*12 + month` as the comparison key, so ranges span multi-year
  boundaries cleanly. Start = day 1; end = last day of the month
  (computed via Date(y, m, 0).getDate()).
- Localised month names via DateFormatter(locale, { month: 'short' }).

Index + CSS:
- index.ts: registers YearView + MonthView on the DateRangePicker
  namespace.
- date-range-picker.css: shared grid layout (3 columns, gap-2),
  hover surface-overlay, selected cells get primary-solid +
  content-on-solid, in-range cells get color-mix(primary-solid 22%,
  transparent) as a stripe with border-radius:0 (start/end keep
  their outer corner radius). Reduced-motion respected.

Demo:
- {#if kind === 'year'} → <YearView>; {:else if 'month'} → <MonthView>;
  {:else} → existing day calendar.
- pickerFooter snippet rendered once at the Content level (removed
  the inline render at the end of calendarBody to avoid duplication).

Verified in browser:
1. kind='year' chip → popover shows year grid '2020 – 2031'.
2. Click 2024 → cell marked start (purple solid).
3. Click 2027 → cell marked end, 2025-2026 marked in-range. trace
   shows 'range 2024-01-01 -> 2027-12-31'. Popover stays open
   (closeOnRangeSelect=false in the demo).

0 type errors, 67/67 component:audit PASS.

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

@ -0,0 +1,124 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { CalendarDate, DateFormatter } from '$libs/days';
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> {}
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();
}
</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>

@ -0,0 +1,110 @@
<script lang="ts">
import { DateRangePickerProvider } from '$soma/components/date-range-picker';
import { CalendarDate } from '$libs/days';
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> {}
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 };
}
</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>

@ -824,6 +824,115 @@
}
}
/* ── Range Year-view + Month-view (kind='year' / 'month') ────────────────── */
[data-date-range-picker-year-view],
[data-date-range-picker-month-view] {
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: min(20rem, 100%);
}
[data-date-range-picker-year-view-header],
[data-date-range-picker-month-view-header] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
[data-date-range-picker-year-view-heading],
[data-date-range-picker-month-view-heading] {
font-weight: var(--font-weight-semibold);
color: var(--color-content-primary);
}
[data-date-range-picker-year-view-prev],
[data-date-range-picker-year-view-next],
[data-date-range-picker-month-view-prev],
[data-date-range-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-range-picker-year-view-prev]:hover,
[data-date-range-picker-year-view-next]:hover,
[data-date-range-picker-month-view-prev]:hover,
[data-date-range-picker-month-view-next]:hover {
background: var(--color-surface-overlay);
}
[data-date-range-picker-year-view-grid],
[data-date-range-picker-month-view-grid] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
[data-date-range-picker-year-view-cell],
[data-date-range-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-range-picker-year-view-cell]:hover,
[data-date-range-picker-month-view-cell]:hover {
background: var(--color-surface-overlay);
}
/* Range stripe: in-range cells get a tinted background. Start and end
* cells overlay it with the solid primary fill. */
[data-date-range-picker-year-view-cell][data-in-range],
[data-date-range-picker-month-view-cell][data-in-range] {
background: color-mix(in srgb, var(--color-primary-solid) 22%, transparent);
border-radius: 0;
}
[data-date-range-picker-year-view-cell][data-range-start],
[data-date-range-picker-year-view-cell][data-range-end],
[data-date-range-picker-month-view-cell][data-range-start],
[data-date-range-picker-month-view-cell][data-range-end] {
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
border-color: var(--color-primary-border);
}
/* Round the open edge of the range strip on start / end cells. */
[data-date-range-picker-year-view-cell][data-range-start],
[data-date-range-picker-month-view-cell][data-range-start] {
border-start-start-radius: var(--radius-md);
border-end-start-radius: var(--radius-md);
}
[data-date-range-picker-year-view-cell][data-range-end],
[data-date-range-picker-month-view-cell][data-range-end] {
border-start-end-radius: var(--radius-md);
border-end-end-radius: var(--radius-md);
}
[data-date-range-picker-year-view-cell]:focus-visible,
[data-date-range-picker-month-view-cell]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
}
/* ── Footer (Clear / Cancel / Close action row) ──────────────────────────── */
[data-date-range-picker-footer] {

@ -10,6 +10,8 @@ import Overlay from '../popover/popover-overlay.svelte';
import Arrow from '../popover/popover-arrow.svelte';
import Close from './date-range-picker-close.svelte';
import Footer from './date-range-picker-footer.svelte';
import YearView from './date-range-picker-year-view.svelte';
import MonthView from './date-range-picker-month-view.svelte';
import Clear from './date-range-picker-clear.svelte';
import Cancel from './date-range-picker-cancel.svelte';
import Header from './date-range-picker-header.svelte';
@ -36,6 +38,8 @@ type DateRangePickerNamespace = typeof DateRangePickerComponent & {
Arrow: typeof Arrow;
Close: typeof Close;
Footer: typeof Footer;
YearView: typeof YearView;
MonthView: typeof MonthView;
Clear: typeof Clear;
Cancel: typeof Cancel;
Label: typeof Label;
@ -66,6 +70,8 @@ DateRangePicker.Content = Content;
DateRangePicker.Arrow = Arrow;
DateRangePicker.Close = Close;
DateRangePicker.Footer = Footer;
DateRangePicker.YearView = YearView;
DateRangePicker.MonthView = MonthView;
DateRangePicker.Clear = Clear;
DateRangePicker.Cancel = Cancel;
DateRangePicker.Label = Label;

@ -539,11 +539,18 @@
</div>
<DateRangePicker.Portal>
<DateRangePicker.Content align="end" sideOffset={10} collisionPadding={12}>
{#if kind === 'year'}
<DateRangePicker.YearView />
{:else if kind === 'month'}
<DateRangePicker.MonthView />
{:else}
<DateRangePicker.Calendar>
{#snippet children(args: DateRangePickerCalendarSnippetProps)}
{@render calendarBody(args)}
{/snippet}
</DateRangePicker.Calendar>
{/if}
{@render pickerFooter()}
</DateRangePicker.Content>
</DateRangePicker.Portal>
</DateRangePicker>
@ -1082,7 +1089,6 @@
</div>
{/each}
</div>
{@render pickerFooter()}
{/snippet}
{#snippet pickerFooter()}

Loading…
Cancel
Save

Powered by TurnKey Linux.