diff --git a/src/uix/eidos/components/date-picker/date-picker-month-view.svelte b/src/uix/eidos/components/date-picker/date-picker-month-view.svelte new file mode 100644 index 000000000..cc209edc9 --- /dev/null +++ b/src/uix/eidos/components/date-picker/date-picker-month-view.svelte @@ -0,0 +1,94 @@ + + +
+
+ + {year} + +
+
+ {#each months as month (month)} + + {/each} +
+
diff --git a/src/uix/eidos/components/date-picker/date-picker-year-view.svelte b/src/uix/eidos/components/date-picker/date-picker-year-view.svelte new file mode 100644 index 000000000..0866d0d07 --- /dev/null +++ b/src/uix/eidos/components/date-picker/date-picker-year-view.svelte @@ -0,0 +1,97 @@ + + +
+
+ + + {decadeStart} – {decadeStart + 11} + + +
+
+ {#each years as year (year)} + + {/each} +
+
diff --git a/src/uix/eidos/components/date-picker/date-picker.css b/src/uix/eidos/components/date-picker/date-picker.css index d6c00fab5..6c0785c98 100644 --- a/src/uix/eidos/components/date-picker/date-picker.css +++ b/src/uix/eidos/components/date-picker/date-picker.css @@ -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; + } } diff --git a/src/uix/eidos/components/date-picker/index.ts b/src/uix/eidos/components/date-picker/index.ts index 5acda8e03..4a55489e1 100644 --- a/src/uix/eidos/components/date-picker/index.ts +++ b/src/uix/eidos/components/date-picker/index.ts @@ -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; diff --git a/src/uix/soma/components/date-field/components/date-field.svelte b/src/uix/soma/components/date-field/components/date-field.svelte index ab479292d..48569c27d 100644 --- a/src/uix/soma/components/date-field/components/date-field.svelte +++ b/src/uix/soma/components/date-field/components/date-field.svelte @@ -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), diff --git a/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts b/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts index e6999c2b6..e61eac4ff 100644 --- a/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts +++ b/src/uix/soma/components/date-field/date-field-provider.svelte.test.ts @@ -94,6 +94,7 @@ function createDateFieldOpts() { value: state(undefined), placeholder: state(new CalendarDate(2026, 1, 1)), readonlySegments: state([]), + kind: state<'date' | 'month' | 'year'>('date'), validate: state(undefined), onInvalid: state(undefined), minValue: state(undefined), diff --git a/src/uix/soma/components/date-field/date-field-provider.svelte.ts b/src/uix/soma/components/date-field/date-field-provider.svelte.ts index 061385544..178627cef 100644 --- a/src/uix/soma/components/date-field/date-field-provider.svelte.ts +++ b/src/uix/soma/components/date-field/date-field-provider.svelte.ts @@ -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 => { + switch (this.opts.kind.current) { + case 'year': + return new Set(['year']); + case 'month': + return new Set(['month', 'year']); + default: + return new Set(['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, diff --git a/src/uix/soma/components/date-field/types.ts b/src/uix/soma/components/date-field/types.ts index 9b571b91c..233b64510 100644 --- a/src/uix/soma/components/date-field/types.ts +++ b/src/uix/soma/components/date-field/types.ts @@ -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 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 e3e5acad4..e64721765 100644 --- a/src/uix/soma/components/date-picker/components/date-picker.svelte +++ b/src/uix/soma/components/date-picker/components/date-picker.svelte @@ -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), diff --git a/src/uix/soma/components/date-range-field/components/date-range-field-input.svelte b/src/uix/soma/components/date-range-field/components/date-range-field-input.svelte index fd579d320..2380b43a8 100644 --- a/src/uix/soma/components/date-range-field/components/date-range-field-input.svelte +++ b/src/uix/soma/components/date-range-field/components/date-range-field-input.svelte @@ -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, diff --git a/web/routes/uix/components/date-picker/+page.svelte b/web/routes/uix/components/date-picker/+page.svelte index cff6de532..4ee42a958 100644 --- a/web/routes/uix/components/date-picker/+page.svelte +++ b/web/routes/uix/components/date-picker/+page.svelte @@ -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>( - kind === 'year' - ? new Set(['year']) - : kind === 'month' - ? new Set(['month', 'year']) - : new Set(['day', 'month', 'year']) - ); const dateDisabledMatcher = $derived( 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( - 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)); @@ -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 @@ {labelText} {#snippet children({ segments }: DatePickerInputSnippetProps)} - {#each filterByKind(segments, visibleDateParts) as { part, value }, index (`${part}-${index}`)} + {#each segments as { part, value }, index (`${part}-${index}`)} {value} {/each} @@ -513,11 +477,27 @@ - - {#snippet children(args: DatePickerCalendarSnippetProps)} - {@render calendarBody(args)} - {/snippet} - + {#if kind === 'year'} + + + + + + + {:else if kind === 'month'} + + + + + + + {:else} + + {#snippet children(args: DatePickerCalendarSnippetProps)} + {@render calendarBody(args)} + {/snippet} + + {/if}