date-picker: kind prop (date/month/year) — commit A scaffolding

User wants Chakra-style behavior: the chip in the demo should drive
both the visible segments in the input AND the calendar view
(year-only grid, month-only grid, day calendar). The previous
'readonly segments' chip was a Bits-style input lock, not a picker
kind switch — wrong contract.

This commit lays the scaffolding for the Chakra model, in four
incremental landings (A → D). It's the FIRST landing.

What changes:
- DatePickerKind type ('date' | 'month' | 'year') exported from soma.
- DatePickerProvider opts gain a `kind` slot; the root .svelte
  defaults to 'date' and threads it through readableActive.
- exports.ts surfaces the new type alongside DatePickerMode.
- The root <div data-date-picker> emits data-kind for downstream
  CSS / picker parts to consume in subsequent commits.
- Test fixture extended with the new opt.

Demo (date-picker single):
- Chip control renamed from 'readonly segments' to 'kind' with values
  date / month / year. A hint shows the current input format
  (YYYY / MM/YYYY / MM/DD/YYYY).
- New `visibleDateParts` derived set drives a `filterByKind` helper
  applied to the segment snippet, so the input renders the right
  subset on first selection. Literals (separators) between dropped
  parts are removed; leading/trailing literals are trimmed.
- Snippet code refs updated: closeButton && readonlySegments line
  replaced with kind !== 'date' && `  kind="${kind}"`.

What this commit DOES NOT do (next commits):
- B: render the year-grid in the calendar popover when data-kind=year.
- C: render the month-grid when data-kind=month.
- D: propagate to date-range-picker + wire the soma date-field to
  drop segments based on kind (currently the filter lives in the
  demo snippet — works for single-picker but isn't a contract for
  third-party consumers).

Verification: 0 type errors. Browser-confirmed: clicking 'year' chip
collapses the input to a single '2026' segment and stamps
data-kind='year' on the picker root. Calendar still shows day-grid
(that's commit B's scope). 67/67 component:audit PASS.

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

@ -24,6 +24,7 @@
onOpenChangeComplete = () => {}, onOpenChangeComplete = () => {},
closeOnDateSelect = true, closeOnDateSelect = true,
mode = 'inline', mode = 'inline',
kind = 'date',
clearButton = false, clearButton = false,
cancelButton = false, cancelButton = false,
closeButton = false, closeButton = false,
@ -138,6 +139,7 @@
required: readableActive(() => required), required: readableActive(() => required),
closeOnDateSelect: readableActive(() => closeOnDateSelect), closeOnDateSelect: readableActive(() => closeOnDateSelect),
mode: readableActive(() => mode), mode: readableActive(() => mode),
kind: readableActive(() => kind),
clearButton: readableActive(() => clearButton), clearButton: readableActive(() => clearButton),
cancelButton: readableActive(() => cancelButton), cancelButton: readableActive(() => cancelButton),
closeButton: readableActive(() => closeButton), closeButton: readableActive(() => closeButton),
@ -196,7 +198,9 @@
errorMessageId: readableActive(() => errorMessageId) errorMessageId: readableActive(() => errorMessageId)
}); });
const mergedProps = $derived(mergeProps(restProps, state.props)); const mergedProps = $derived(
mergeProps(restProps, state.props, { 'data-kind': kind } as Record<string, unknown>)
);
</script> </script>
{#if child} {#if child}

@ -20,7 +20,11 @@ import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import type { Direction, OnChangeFn } from '../../types'; import type { Direction, OnChangeFn } from '../../types';
import { DatePickerProvider, type DatePickerMode } from './date-picker-provider.svelte'; import {
DatePickerProvider,
type DatePickerKind,
type DatePickerMode
} from './date-picker-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } { function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T; let result!: T;
@ -73,6 +77,7 @@ function createDatePickerOpts() {
required: state(false), required: state(false),
closeOnDateSelect: state(true), closeOnDateSelect: state(true),
mode: state<DatePickerMode>('inline'), mode: state<DatePickerMode>('inline'),
kind: state<DatePickerKind>('date'),
clearButton: state(false), clearButton: state(false),
cancelButton: state(false), cancelButton: state(false),
closeButton: state(false), closeButton: state(false),

@ -26,6 +26,21 @@ import type {
*/ */
export type DatePickerMode = 'inline' | 'modal'; export type DatePickerMode = 'inline' | 'modal';
/**
* Picker granularity / type of date (Chakra-style). Drives both the
* visible segments in the input AND the calendar view in the popover:
*
* - `'date'` (default): MM/DD/YYYY input + day grid in popover
* - `'month'`: MM/YYYY input + month grid in popover
* - `'year'`: YYYY input + year grid (decade) in popover
*
* The contract is consumer-driven for now — the picker exposes `kind`,
* the consumer's segment snippet filters what to render, and the
* calendar honours `data-kind` for view switching (forthcoming
* commits B + C wire the grid renderers).
*/
export type DatePickerKind = 'date' | 'month' | 'year';
// ── Contract ───────────────────────────────────────────────────────────────── // ── Contract ─────────────────────────────────────────────────────────────────
import { datePickerMorfo } from '../../../morfo/components/date-picker'; import { datePickerMorfo } from '../../../morfo/components/date-picker';
@ -58,6 +73,7 @@ interface DatePickerOpts
required: boolean; required: boolean;
closeOnDateSelect: boolean; closeOnDateSelect: boolean;
mode: DatePickerMode; mode: DatePickerMode;
kind: DatePickerKind;
clearButton: boolean; clearButton: boolean;
cancelButton: boolean; cancelButton: boolean;
closeButton: boolean; closeButton: boolean;

@ -5,7 +5,7 @@ export { default as Calendar } from './components/date-picker-calendar.svelte';
// Provider class (action helpers: commit / cancel / clear / handleDateSelect) // Provider class (action helpers: commit / cancel / clear / handleDateSelect)
export { DatePickerProvider, datePickerAttrs } from './date-picker-provider.svelte'; export { DatePickerProvider, datePickerAttrs } from './date-picker-provider.svelte';
export type { DatePickerMode } from './date-picker-provider.svelte'; export type { DatePickerKind, DatePickerMode } from './date-picker-provider.svelte';
// Popover surface (shared via composed PopoverProvider) // Popover surface (shared via composed PopoverProvider)
export { export {

@ -63,6 +63,17 @@ type DatePickerBaseProps = {
* @default 'inline' * @default 'inline'
*/ */
mode?: import('./date-picker-provider.svelte').DatePickerMode; mode?: import('./date-picker-provider.svelte').DatePickerMode;
/**
* Picker granularity / Chakra-style date kind. Drives the visible
* segments in the input AND the calendar view in the popover.
*
* - `'date'` (default): MM/DD/YYYY input + day grid
* - `'month'`: MM/YYYY input + month grid
* - `'year'`: YYYY input + year grid (decade)
*
* @default 'date'
*/
kind?: import('./date-picker-provider.svelte').DatePickerKind;
/** /**
* Whether the footer renders a Clear button (resets value, keeps popover * Whether the footer renders a Clear button (resets value, keeps popover
* open). @default false * open). @default false

@ -22,8 +22,6 @@
type WeekStartsOn type WeekStartsOn
} from '$libs/days'; } from '$libs/days';
type SegmentLock = 'none' | 'year' | 'monthYear' | 'date';
const uix = getActiveUix(); const uix = getActiveUix();
void uix; void uix;
@ -58,10 +56,10 @@
let readonly = $state(false); let readonly = $state(false);
let closeOnDateSelect = $state(false); let closeOnDateSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline'); let mode = $state<'inline' | 'modal'>('inline');
let kind = $state<'date' | 'month' | 'year'>('date');
let clearButton = $state(true); let clearButton = $state(true);
let cancelButton = $state(true); let cancelButton = $state(true);
let closeButton = $state(true); let closeButton = $state(true);
let segmentLock = $state<SegmentLock>('none');
let disableDaysOutsideMonth = $state(true); let disableDaysOutsideMonth = $state(true);
let fixedWeeks = $state(false); let fixedWeeks = $state(false);
let pagedNavigation = $state(false); let pagedNavigation = $state(false);
@ -100,14 +98,17 @@
const resolvedWeekStartsOn = $derived( const resolvedWeekStartsOn = $derived(
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn) weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
); );
const readonlySegments = $derived<EditableSegmentPart[]>( /**
segmentLock === 'year' * Chakra-style kind → set of date-segment parts to show. The chip
? ['year'] * `kind='year'` hides month + day from the input; `kind='month'`
: segmentLock === 'monthYear' * hides only day. `'date'` shows the full triplet (default).
? ['month', 'year'] */
: segmentLock === 'date' const visibleDateParts = $derived<Set<EditableSegmentPart>>(
? ['day', 'month', 'year'] kind === 'year'
: [] ? new Set(['year'])
: kind === 'month'
? new Set(['month', 'year'])
: new Set(['day', 'month', 'year'])
); );
const dateDisabledMatcher = $derived<DateMatcher | undefined>( const dateDisabledMatcher = $derived<DateMatcher | undefined>(
blockWeekends ? isWeekend : undefined blockWeekends ? isWeekend : undefined
@ -148,6 +149,39 @@
return day === 0 || day === 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 { function isBooked(date: DateValue): boolean {
return bookedDates.some((item) => sameDate(item, date)); return bookedDates.some((item) => sameDate(item, date));
} }
@ -282,7 +316,7 @@
clearButton && ' clearButton', clearButton && ' clearButton',
cancelButton && ' cancelButton', cancelButton && ' cancelButton',
closeButton && ' closeButton', closeButton && ' closeButton',
readonlySegments.length && ` readonlySegments={${JSON.stringify(readonlySegments)}}`, kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks', fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation', pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect', preventDeselect && ' preventDeselect',
@ -357,7 +391,7 @@
clearButton && ' clearButton', clearButton && ' clearButton',
cancelButton && ' cancelButton', cancelButton && ' cancelButton',
closeButton && ' closeButton', closeButton && ' closeButton',
readonlySegments.length && ` readonlySegments={${JSON.stringify(readonlySegments)}}`, kind !== 'date' && ` kind="${kind}"`,
fixedWeeks && ' fixedWeeks', fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation', pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect', preventDeselect && ' preventDeselect',
@ -453,10 +487,10 @@
{variant} {variant}
{color} {color}
{mode} {mode}
{kind}
{clearButton} {clearButton}
{cancelButton} {cancelButton}
{closeButton} {closeButton}
{readonlySegments}
minValue={minValue} minValue={minValue}
maxValue={maxValue} maxValue={maxValue}
weekStartsOn={resolvedWeekStartsOn} weekStartsOn={resolvedWeekStartsOn}
@ -468,7 +502,7 @@
<DatePicker.Label>{labelText}</DatePicker.Label> <DatePicker.Label>{labelText}</DatePicker.Label>
<DatePicker.Input name={inputName} aria-label={labelText}> <DatePicker.Input name={inputName} aria-label={labelText}>
{#snippet children({ segments }: DatePickerInputSnippetProps)} {#snippet children({ segments }: DatePickerInputSnippetProps)}
{#each segments as { part, value }, index (`${part}-${index}`)} {#each filterByKind(segments, visibleDateParts) as { part, value }, index (`${part}-${index}`)}
<DatePicker.Segment {part}>{value}</DatePicker.Segment> <DatePicker.Segment {part}>{value}</DatePicker.Segment>
{/each} {/each}
<DatePicker.Trigger /> <DatePicker.Trigger />
@ -642,13 +676,15 @@
<span data-uix-switch><input type="checkbox" bind:checked={closeButton} /><span data-uix-switch-label>close</span></span> <span data-uix-switch><input type="checkbox" bind:checked={closeButton} /><span data-uix-switch-label>close</span></span>
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>readonly segments</span> <span data-uix-control-label
>kind <span data-uix-control-hint>{kind === 'year' ? 'YYYY' : kind === 'month' ? 'MM/YYYY' : 'MM/DD/YYYY'}</span></span
>
<span data-uix-chips role="radiogroup"> <span data-uix-chips role="radiogroup">
{#each ['none', 'year', 'monthYear', 'date'] as value (value)} {#each ['date', 'month', 'year'] as value (value)}
<button <button
data-uix-chip data-uix-chip
data-active={segmentLock === value} data-active={kind === value}
onclick={() => (segmentLock = value as SegmentLock)} onclick={() => (kind = value as 'date' | 'month' | 'year')}
> >
{value} {value}
</button> </button>

Loading…
Cancel
Save

Powered by TurnKey Linux.