date-picker: kind derives segments at soma layer + year/month views

User correctly pointed out: the input segments depend on the kind of
calendar — they're *derived* values, not independently controlled.
Filtering segments in the demo snippet (commit A) was wrong; the
derivation belongs in the DateField provider.

This commit refactors the contract + lands the year/month-grid views
(combining commit B + C into one).

Architecture fix (segments derive from kind):
- DateFieldProvider opts gain `kind: 'date' | 'month' | 'year'`.
- `segmentContents` filters `allSegmentContent.arr` by a derived
  `visibleDatePartsByKind` set, collapsing runs of literals and
  trimming leading/trailing separators. Time segments (hour/minute/
  second/dayPeriod) are passed through untouched — `kind` is
  orthogonal to `granularity`.
- DateField root component accepts `kind` prop and threads it.
- DatePicker root forwards its `kind` to the DateField provider it
  creates internally.
- DateRangeField passes `kind: 'date'` as a constant for now; range
  propagation lands in commit D.
- Test fixtures extended with the new opt.

New Eidos parts (commit B + C in one shot):
- `<DatePicker.YearView>`: 3×4 decade grid centred on placeholder's
  decade boundary. Header shows the decade range with prev/next
  decade buttons. Click on a year sets value to (year, 1, 1) and
  calls handleDateSelect (closes if closeOnDateSelect=true inline).
- `<DatePicker.MonthView>`: 3×4 month grid for the placeholder's
  year. Localised month names via DateFormatter. Header shows the
  year with prev/next year buttons. Click sets (year, month, 1).
- CSS for both: shared 3-column grid layout, hover surface-overlay
  background, selected cell uses primary-solid + content-on-solid.
  focus-visible outline. prefers-reduced-motion honoured.

Demo wiring:
- Removed the local `filterByKind` helper — soma derives it now.
- The snippet just iterates `segments` as it comes.
- The popover content branches on `kind`: Calendar for 'date',
  MonthView for 'month', YearView for 'year'. The Footer renders in
  all three branches.
- An $effect re-opens the popover whenever `kind` changes (clicking
  the chip outside the popover would otherwise close it in inline
  mode).

What this commit DOES NOT do (commit D):
- Propagate `kind` to date-range-picker (start + end inputs + the
  range-calendar popover view-mode).
- date-range-field consumes `kind: 'date'` only for now.

Verification: 0 type errors, 11/11 date-field + date-picker tests,
67/67 component:audit PASS. Browser confirmed:
- kind=date → MM/DD/YYYY input + day calendar
- kind=month → MM/YYYY input + month grid (2026)
- kind=year → YYYY input + year grid (2020 – 2031)

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

@ -0,0 +1,94 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { CalendarDate, DateFormatter } from '$libs/days';
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.
interface MonthViewProps extends HTMLAttributes<HTMLDivElement> {}
const { ...rest }: 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 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));
}
function selectMonth(month: number) {
const next = new CalendarDate(year, month, 1);
provider.opts.value.current = next;
provider.opts.placeholder.current = next;
provider.handleDateSelect();
}
function stepYear(direction: -1 | 1) {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction,
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}
>
<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>
{#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>
{/each}
</div>
</div>

@ -0,0 +1,97 @@
<script lang="ts">
import { DatePickerProvider } from '$soma/components/date-picker';
import { getActiveUix } from '$active-uix';
import { CalendarDate } from '$libs/days';
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.
interface YearViewProps extends HTMLAttributes<HTMLDivElement> {}
const { ...rest }: 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
);
// 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));
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;
provider.handleDateSelect();
}
function stepDecade(direction: -1 | 1) {
const placeholder = provider.opts.placeholder.current;
provider.opts.placeholder.current = new CalendarDate(
placeholder.year + direction * 10,
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}
>
<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>
{#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>
{/each}
</div>
</div>

@ -243,6 +243,92 @@
outline-offset: var(--focus-ring-offset);
}
/* ── 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);
}
@media (prefers-reduced-motion: reduce) {
[data-date-picker-trigger] {
transition: none;
@ -252,4 +338,8 @@
[data-date-picker-close] {
transition: none;
}
[data-date-picker-year-view-cell],
[data-date-picker-month-view-cell] {
transition: none;
}
}

@ -6,6 +6,8 @@ import Footer from './date-picker-footer.svelte';
import Clear from './date-picker-clear.svelte';
import Cancel from './date-picker-cancel.svelte';
import Close from './date-picker-close.svelte';
import YearView from './date-picker-year-view.svelte';
import MonthView from './date-picker-month-view.svelte';
import Label from '../date-field/date-field-label.svelte';
import Input from '../date-field/date-field-input.svelte';
import Segment from '../date-field/date-field-segment.svelte';
@ -39,6 +41,8 @@ type DatePickerNamespace = typeof DatePickerComponent & {
Footer: typeof Footer;
Clear: typeof Clear;
Cancel: typeof Cancel;
YearView: typeof YearView;
MonthView: typeof MonthView;
Label: typeof Label;
Input: typeof Input;
Segment: typeof Segment;
@ -70,6 +74,8 @@ DatePicker.Close = Close;
DatePicker.Footer = Footer;
DatePicker.Clear = Clear;
DatePicker.Cancel = Cancel;
DatePicker.YearView = YearView;
DatePicker.MonthView = MonthView;
DatePicker.Label = Label;
DatePicker.Input = Input;
DatePicker.Segment = Segment;

@ -33,6 +33,7 @@
disabled = false,
readonly = false,
readonlySegments = [],
kind = 'date',
granularity,
hideTimeZone = false,
required = false,
@ -95,6 +96,7 @@
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
readonlySegments: readableActive(() => readonlySegments),
kind: readableActive(() => kind),
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),
hourCycle: readableActive(() => hourCycle),

@ -94,6 +94,7 @@ function createDateFieldOpts() {
value: state<DateValue | undefined>(undefined),
placeholder: state<DateValue>(new CalendarDate(2026, 1, 1)),
readonlySegments: state<EditableSegmentPart[]>([]),
kind: state<'date' | 'month' | 'year'>('date'),
validate: state<DateValidator | undefined>(undefined),
onInvalid: state<DateOnInvalid | undefined>(undefined),
minValue: state<DateValue | undefined>(undefined),

@ -155,6 +155,20 @@ export interface DateFieldOpts
}>,
ActiveProps<{
readonlySegments: EditableSegmentPart[];
/**
* Chakra-style picker kind. Drives which date segments are
* emitted in `segmentContents` (and therefore which segment
* elements the consumer's snippet renders):
*
* - `'date'` (default): day + month + year segments
* - `'month'`: month + year segments
* - `'year'`: year segment only
*
* `granularity` (time precision: day/hour/minute/second) is
* orthogonal — `kind='year'` + `granularity='hour'` is invalid
* by construction (year-only pickers don't have hours).
*/
kind: 'date' | 'month' | 'year';
validate: DateValidator | undefined;
onInvalid: DateOnInvalid | undefined;
minValue: DateValue | undefined;
@ -581,7 +595,56 @@ export class DateFieldProvider {
})
);
readonly segmentContents = $derived.by(() => this.allSegmentContent.arr);
/**
* Visible segment parts derived from `kind` (Chakra-style picker).
* `'date'` keeps the full day+month+year triplet; `'month'` drops
* day; `'year'` drops day AND month. Time segments (hour/minute/
* second/dayPeriod) are governed by `granularity`, not `kind`, so
* we leave them untouched.
*/
readonly visibleDatePartsByKind = $derived.by((): Set<EditableSegmentPart> => {
switch (this.opts.kind.current) {
case 'year':
return new Set<EditableSegmentPart>(['year']);
case 'month':
return new Set<EditableSegmentPart>(['month', 'year']);
default:
return new Set<EditableSegmentPart>(['day', 'month', 'year']);
}
});
readonly segmentContents = $derived.by(() => {
const arr = this.allSegmentContent.arr;
const visible = this.visibleDatePartsByKind;
// Fast-path for the full triplet.
if (visible.size === 3) return arr;
// Filter date parts not in the visible set, then collapse runs of
// literals to a single one between any two visible parts. Time
// segments (hour/minute/second/dayPeriod) and their separators
// are passed through untouched.
const isDateOnly = (part: string) =>
part === 'day' || part === 'month' || part === 'year';
const flat = arr.filter((seg) =>
isDateOnly(seg.part) ? visible.has(seg.part as EditableSegmentPart) : true
);
const out: typeof arr[number][] = [];
let prevWasDate = false;
for (const seg of flat) {
if (seg.part === 'literal') {
if (prevWasDate) {
out.push(seg);
prevWasDate = false;
}
} else {
out.push(seg);
prevWasDate = isDateOnly(seg.part);
}
}
// Drop a trailing literal that the loop above may have left.
while (out.length && out[out.length - 1].part === 'literal') out.pop();
while (out.length && out[0].part === 'literal') out.shift();
return out;
});
readonly sharedSegmentAttrs = {
role: 'spinbutton' as const,

@ -72,6 +72,19 @@ export type DateFieldProviderProps = WithChild<{
* @default []
*/
readonlySegments?: EditableSegmentPart[];
/**
* Chakra-style picker kind. Drives which date segments are emitted:
*
* - `'date'` (default): day + month + year segments
* - `'month'`: month + year segments only
* - `'year'`: year segment only
*
* Orthogonal to `granularity` (which governs time precision —
* hour/minute/second).
*
* @default 'date'
*/
kind?: 'date' | 'month' | 'year';
/**
* Visual granularity override. By default CalendarDate → 'day',
* CalendarDateTime / ZonedDateTime → 'minute'. Granularity only affects

@ -189,6 +189,7 @@
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
readonlySegments: readableActive(() => readonlySegments),
kind: readableActive(() => kind),
required: readableActive(() => required),
granularity: readableActive(() => granularity),
hideTimeZone: readableActive(() => hideTimeZone),

@ -75,6 +75,9 @@
? provider.opts.startReadonlySegments.current
: provider.opts.endReadonlySegments.current
),
// Date-range-field has no `kind` notion yet — endpoints stay as
// full date segments. Range-picker propagation lands in commit D.
kind: readableActive(() => 'date' as const),
required: readableActive(() => provider.isRequired),
granularity: provider.opts.granularity,
hideTimeZone: provider.opts.hideTimeZone,

@ -18,7 +18,6 @@
CalendarDate,
type DateMatcher,
type DateValue,
type EditableSegmentPart,
type WeekStartsOn
} from '$libs/days';
@ -98,18 +97,6 @@
const resolvedWeekStartsOn = $derived(
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
);
/**
* Chakra-style kind → set of date-segment parts to show. The chip
* `kind='year'` hides month + day from the input; `kind='month'`
* hides only day. `'date'` shows the full triplet (default).
*/
const visibleDateParts = $derived<Set<EditableSegmentPart>>(
kind === 'year'
? new Set(['year'])
: kind === 'month'
? new Set(['month', 'year'])
: new Set(['day', 'month', 'year'])
);
const dateDisabledMatcher = $derived<DateMatcher | undefined>(
blockWeekends ? isWeekend : undefined
);
@ -149,38 +136,6 @@
return day === 0 || day === 6;
}
/**
* Filter the input segments by the configured `kind`. Keeps only the
* date parts in `visibleParts` plus the literals (separators) that sit
* between two kept parts. Used by the snippet to render `YYYY` /
* `MM/YYYY` / `MM/DD/YYYY` depending on the picker kind.
*/
function filterByKind<S extends { part: string; value: string }>(
segments: readonly S[],
visibleParts: Set<EditableSegmentPart>
): S[] {
const kept: S[] = [];
let lastKeptWasDate = false;
for (const seg of segments) {
if (seg.part === 'literal') {
// Defer adding until we know the next date part is kept.
if (lastKeptWasDate) kept.push(seg);
} else if (visibleParts.has(seg.part as EditableSegmentPart)) {
kept.push(seg);
lastKeptWasDate = true;
continue;
} else {
continue;
}
if (seg.part !== 'literal') lastKeptWasDate = true;
}
// Drop trailing literal if any (e.g. when last kept was a literal
// that came after the last kept date part).
while (kept.length && kept[kept.length - 1].part === 'literal') kept.pop();
// Drop leading literal too.
while (kept.length && kept[0].part === 'literal') kept.shift();
return kept;
}
function isBooked(date: DateValue): boolean {
return bookedDates.some((item) => sameDate(item, date));
@ -264,6 +219,15 @@
valueInput = value ? toDateInput(value) : '';
});
// Re-open the popover whenever the kind changes — clicking the kind
// chip is outside the popover and closes it in inline mode. The demo
// wants the new view (Calendar / MonthView / YearView) visible
// immediately so the change is observable.
$effect(() => {
void kind;
open = true;
});
$effect(() => {
const el = stageRef;
if (!el) return;
@ -502,7 +466,7 @@
<DatePicker.Label>{labelText}</DatePicker.Label>
<DatePicker.Input name={inputName} aria-label={labelText}>
{#snippet children({ segments }: DatePickerInputSnippetProps)}
{#each filterByKind(segments, visibleDateParts) as { part, value }, index (`${part}-${index}`)}
{#each segments as { part, value }, index (`${part}-${index}`)}
<DatePicker.Segment {part}>{value}</DatePicker.Segment>
{/each}
<DatePicker.Trigger />
@ -513,11 +477,27 @@
</div>
<DatePicker.Portal>
<DatePicker.Content align="end" sideOffset={10} collisionPadding={12}>
<DatePicker.Calendar>
{#snippet children(args: DatePickerCalendarSnippetProps)}
{@render calendarBody(args)}
{/snippet}
</DatePicker.Calendar>
{#if kind === 'year'}
<DatePicker.YearView />
<DatePicker.Footer>
<DatePicker.Clear />
<DatePicker.Cancel />
<DatePicker.Close />
</DatePicker.Footer>
{:else if kind === 'month'}
<DatePicker.MonthView />
<DatePicker.Footer>
<DatePicker.Clear />
<DatePicker.Cancel />
<DatePicker.Close />
</DatePicker.Footer>
{:else}
<DatePicker.Calendar>
{#snippet children(args: DatePickerCalendarSnippetProps)}
{@render calendarBody(args)}
{/snippet}
</DatePicker.Calendar>
{/if}
</DatePicker.Content>
</DatePicker.Portal>
</DatePicker>

Loading…
Cancel
Save

Powered by TurnKey Linux.