From 46ce1d800d75c1bfbb2c52ef07bcc9397fd9f57c Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 21 May 2026 14:13:16 +0200 Subject: [PATCH] =?UTF-8?q?date-picker:=20kind=20prop=20(date/month/year)?= =?UTF-8?q?=20=E2=80=94=20commit=20A=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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) --- .../date-picker/components/date-picker.svelte | 6 +- .../date-picker-provider.svelte.test.ts | 7 +- .../date-picker-provider.svelte.ts | 16 ++++ .../soma/components/date-picker/exports.ts | 2 +- src/uix/soma/components/date-picker/types.ts | 11 +++ .../uix/components/date-picker/+page.svelte | 74 ++++++++++++++----- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/src/uix/soma/components/date-picker/components/date-picker.svelte b/src/uix/soma/components/date-picker/components/date-picker.svelte index 2394fde43..e3e5acad4 100644 --- a/src/uix/soma/components/date-picker/components/date-picker.svelte +++ b/src/uix/soma/components/date-picker/components/date-picker.svelte @@ -24,6 +24,7 @@ onOpenChangeComplete = () => {}, closeOnDateSelect = true, mode = 'inline', + kind = 'date', clearButton = false, cancelButton = false, closeButton = false, @@ -138,6 +139,7 @@ required: readableActive(() => required), closeOnDateSelect: readableActive(() => closeOnDateSelect), mode: readableActive(() => mode), + kind: readableActive(() => kind), clearButton: readableActive(() => clearButton), cancelButton: readableActive(() => cancelButton), closeButton: readableActive(() => closeButton), @@ -196,7 +198,9 @@ errorMessageId: readableActive(() => errorMessageId) }); - const mergedProps = $derived(mergeProps(restProps, state.props)); + const mergedProps = $derived( + mergeProps(restProps, state.props, { 'data-kind': kind } as Record) + ); {#if child} diff --git a/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts b/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts index 5d9c77563..7ee0d1ca6 100644 --- a/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts +++ b/src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts @@ -20,7 +20,11 @@ import { Soma } from '$soma/core/soma.svelte'; import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte'; 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(fn: () => T): { result: T; cleanup: () => void } { let result!: T; @@ -73,6 +77,7 @@ function createDatePickerOpts() { required: state(false), closeOnDateSelect: state(true), mode: state('inline'), + kind: state('date'), clearButton: state(false), cancelButton: state(false), closeButton: state(false), diff --git a/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts b/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts index 0c6d55dcf..6c4538505 100644 --- a/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts +++ b/src/uix/soma/components/date-picker/date-picker-provider.svelte.ts @@ -26,6 +26,21 @@ import type { */ 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 ───────────────────────────────────────────────────────────────── import { datePickerMorfo } from '../../../morfo/components/date-picker'; @@ -58,6 +73,7 @@ interface DatePickerOpts required: boolean; closeOnDateSelect: boolean; mode: DatePickerMode; + kind: DatePickerKind; clearButton: boolean; cancelButton: boolean; closeButton: boolean; diff --git a/src/uix/soma/components/date-picker/exports.ts b/src/uix/soma/components/date-picker/exports.ts index 383ccca8c..d6affbc01 100644 --- a/src/uix/soma/components/date-picker/exports.ts +++ b/src/uix/soma/components/date-picker/exports.ts @@ -5,7 +5,7 @@ export { default as Calendar } from './components/date-picker-calendar.svelte'; // Provider class (action helpers: commit / cancel / clear / handleDateSelect) 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) export { diff --git a/src/uix/soma/components/date-picker/types.ts b/src/uix/soma/components/date-picker/types.ts index 7e578c85b..7bd40ce8c 100644 --- a/src/uix/soma/components/date-picker/types.ts +++ b/src/uix/soma/components/date-picker/types.ts @@ -63,6 +63,17 @@ type DatePickerBaseProps = { * @default 'inline' */ 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 * open). @default false diff --git a/web/routes/uix/components/date-picker/+page.svelte b/web/routes/uix/components/date-picker/+page.svelte index c7f91c850..cff6de532 100644 --- a/web/routes/uix/components/date-picker/+page.svelte +++ b/web/routes/uix/components/date-picker/+page.svelte @@ -22,8 +22,6 @@ type WeekStartsOn } from '$libs/days'; - type SegmentLock = 'none' | 'year' | 'monthYear' | 'date'; - const uix = getActiveUix(); void uix; @@ -58,10 +56,10 @@ let readonly = $state(false); let closeOnDateSelect = $state(false); let mode = $state<'inline' | 'modal'>('inline'); + let kind = $state<'date' | 'month' | 'year'>('date'); let clearButton = $state(true); let cancelButton = $state(true); let closeButton = $state(true); - let segmentLock = $state('none'); let disableDaysOutsideMonth = $state(true); let fixedWeeks = $state(false); let pagedNavigation = $state(false); @@ -100,14 +98,17 @@ const resolvedWeekStartsOn = $derived( weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn) ); - const readonlySegments = $derived( - segmentLock === 'year' - ? ['year'] - : segmentLock === 'monthYear' - ? ['month', 'year'] - : segmentLock === 'date' - ? ['day', 'month', 'year'] - : [] + /** + * 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>( + kind === 'year' + ? new Set(['year']) + : kind === 'month' + ? new Set(['month', 'year']) + : new Set(['day', 'month', 'year']) ); const dateDisabledMatcher = $derived( blockWeekends ? isWeekend : undefined @@ -148,6 +149,39 @@ 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( + segments: readonly S[], + visibleParts: Set + ): 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)); } @@ -282,7 +316,7 @@ clearButton && ' clearButton', cancelButton && ' cancelButton', closeButton && ' closeButton', - readonlySegments.length && ` readonlySegments={${JSON.stringify(readonlySegments)}}`, + kind !== 'date' && ` kind="${kind}"`, fixedWeeks && ' fixedWeeks', pagedNavigation && ' pagedNavigation', preventDeselect && ' preventDeselect', @@ -357,7 +391,7 @@ clearButton && ' clearButton', cancelButton && ' cancelButton', closeButton && ' closeButton', - readonlySegments.length && ` readonlySegments={${JSON.stringify(readonlySegments)}}`, + kind !== 'date' && ` kind="${kind}"`, fixedWeeks && ' fixedWeeks', pagedNavigation && ' pagedNavigation', preventDeselect && ' preventDeselect', @@ -453,10 +487,10 @@ {variant} {color} {mode} + {kind} {clearButton} {cancelButton} {closeButton} - {readonlySegments} minValue={minValue} maxValue={maxValue} weekStartsOn={resolvedWeekStartsOn} @@ -468,7 +502,7 @@ {labelText} {#snippet children({ segments }: DatePickerInputSnippetProps)} - {#each segments as { part, value }, index (`${part}-${index}`)} + {#each filterByKind(segments, visibleDateParts) as { part, value }, index (`${part}-${index}`)} {value} {/each} @@ -642,13 +676,15 @@ close