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}