From 13ee385b599818a649276805a190bd66d24b0b66 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 6 Mar 2026 10:37:26 +0100 Subject: [PATCH 1/6] feat(calendar): add month and year picker modes --- .../calendar/CalendarMonthPickerExample.vue | 30 + .../calendar/CalendarYearPickerExample.vue | 19 + docs/content/docs/2.components/calendar.md | 79 +++ src/runtime/components/Calendar.vue | 586 ++++++++++++++++-- src/theme/calendar.ts | 189 ++++-- test/components/Calendar.spec.ts | 276 ++++++++- .../__snapshots__/Calendar-vue.spec.ts.snap | 138 +++-- .../__snapshots__/Calendar.spec.ts.snap | 138 +++-- 8 files changed, 1267 insertions(+), 188 deletions(-) create mode 100644 docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue create mode 100644 docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue diff --git a/docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue b/docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue new file mode 100644 index 0000000000..0d71a682b4 --- /dev/null +++ b/docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue @@ -0,0 +1,30 @@ + + + diff --git a/docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue b/docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue new file mode 100644 index 0000000000..01530c8f65 --- /dev/null +++ b/docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue @@ -0,0 +1,19 @@ + + + diff --git a/docs/content/docs/2.components/calendar.md b/docs/content/docs/2.components/calendar.md index cb26a7b2fd..7f87fc6fbc 100644 --- a/docs/content/docs/2.components/calendar.md +++ b/docs/content/docs/2.components/calendar.md @@ -209,6 +209,65 @@ props: --- :: +### Type + +Use the `type` prop to change the calendar picker type. Defaults to `date`. + +Set `type="month"` for a month-only picker. + +::component-code +--- +cast: + modelValue: DateValue +ignore: + - modelValue + - type +external: + - modelValue +props: + type: month + modelValue: [2022, 3, 1] +--- +:: + +Set `type="year"` for a year-only picker. + +::component-code +--- +cast: + modelValue: DateValue +ignore: + - modelValue + - type +external: + - modelValue +props: + type: year + modelValue: [2022, 1, 1] +--- +:: + +### Default View + +Use the `default-view` prop to set the initial view. Defaults to `day` when `type="date"`. + +::component-code +--- +cast: + defaultValue: DateValue +ignore: + - defaultView + - defaultValue +external: + - defaultValue +props: + defaultView: month + defaultValue: [2022, 2, 3] +--- +:: + +When using `type="date"`, the heading buttons let users switch between day, month, and year views. + ## Examples ### With chip events @@ -275,6 +334,26 @@ name: 'calendar-external-controls-example' --- :: +### As a month picker + +Use `type="month"` with a [Popover](/docs/components/popover) to create a month picker. + +::component-example +--- +name: 'calendar-month-picker-example' +--- +:: + +### As a year picker + +Use `type="year"` with a [Popover](/docs/components/popover) to create a year picker. + +::component-example +--- +name: 'calendar-year-picker-example' +--- +:: + ### As a date picker Use a [Button](/docs/components/button) and a [Popover](/docs/components/popover) component to create a date picker. diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index 19bc43fd84..368a599913 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -3,6 +3,7 @@ import type { CalendarRootProps, CalendarRootEmits, RangeCalendarRootProps, Rang import { getWeekNumber } from 'reka-ui/date' import type { VNode } from 'vue' import type { DateValue } from '@internationalized/date' +import { getLocalTimeZone, today } from '@internationalized/date' import type { AppConfig } from '@nuxt/schema' import theme from '#build/ui/calendar' import type { ButtonProps, IconProps, LinkPropsKeys } from '../types' @@ -10,6 +11,9 @@ import type { ComponentConfig } from '../types/tv' type Calendar = ComponentConfig +export type CalendarType = 'date' | 'month' | 'year' +export type CalendarView = 'day' | 'month' | 'year' + type CalendarDefaultValue = R extends true ? DateRange : M extends true @@ -30,6 +34,18 @@ export interface CalendarProps extends Omit { 'update:modelValue': [value: CalendarModelValue] + 'update:placeholder': [date: DateValue] + 'update:view': [view: CalendarView] } export interface CalendarSlots { - 'heading'?: (props: { value: string }) => VNode[] - 'day'?: (props: Pick) => VNode[] - 'week-day'?: (props: { day: string }) => VNode[] + 'heading'?(props: { + value: string + date: DateValue + view: CalendarView + setMonth: (date: DateValue) => void + setYear: (date: DateValue) => void + setView: (view: CalendarView) => void + }): VNode[] + 'day'?(props: Pick): VNode[] + 'week-day'?(props: { day: string }): VNode[] + 'month-cell'?(props: { month: DateValue, selected: boolean, disabled: boolean }): VNode[] + 'year-cell'?(props: { year: DateValue, selected: boolean, disabled: boolean }): VNode[] } diff --git a/src/theme/calendar.ts b/src/theme/calendar.ts index 1339bb2527..a09ab88cfd 100644 --- a/src/theme/calendar.ts +++ b/src/theme/calendar.ts @@ -1,11 +1,39 @@ import type { ModuleOptions } from '../module' +type PickerTriggerVariant = 'solid' | 'outline' | 'soft' | 'subtle' + +function getPickerTriggerClass(color: string, variant: PickerTriggerVariant) { + switch (variant) { + case 'solid': + return `data-[selected]:bg-${color} data-[selected]:text-inverted data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + case 'outline': + return `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` + case 'soft': + return `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + case 'subtle': + return `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25 data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + } +} + +function getNeutralPickerTriggerClass(variant: PickerTriggerVariant) { + switch (variant) { + case 'solid': + return 'data-[selected]:bg-inverted data-[selected]:text-inverted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + case 'outline': + return 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10' + case 'soft': + return 'data-[selected]:bg-elevated data-[selected]:text-default data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + case 'subtle': + return 'data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + } +} + export default (options: Required) => ({ slots: { root: '', header: 'flex items-center justify-between', body: 'flex flex-col space-y-4 pt-4 sm:flex-row sm:space-x-4 sm:space-y-0', - heading: 'text-center font-medium truncate mx-auto', + heading: 'mx-auto text-center font-medium', grid: 'w-full border-collapse select-none space-y-1 focus:outline-none', gridRow: 'grid grid-cols-7 place-items-center', gridWeekDaysRow: 'mb-1 grid w-full grid-cols-7', @@ -14,17 +42,29 @@ export default (options: Required) => ({ headCellWeek: 'rounded-md text-muted', cell: 'relative text-center', cellTrigger: ['m-0.5 relative flex items-center justify-center rounded-full whitespace-nowrap focus-visible:ring-2 focus:outline-none data-disabled:text-muted data-unavailable:line-through data-unavailable:text-muted data-unavailable:pointer-events-none data-today:font-semibold data-[outside-view]:text-muted', options.theme.transitions && 'transition'], - cellWeek: 'relative text-center text-muted' + cellWeek: 'relative text-center text-muted', + monthGrid: 'w-full select-none space-y-1 focus:outline-none', + monthGridRow: 'grid grid-cols-4 gap-1', + monthCell: 'relative text-center', + monthCellTrigger: ['relative flex w-full items-center justify-center rounded-md whitespace-nowrap focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition'], + yearGrid: 'w-full select-none space-y-1 focus:outline-none', + yearGridRow: 'grid grid-cols-4 gap-1', + yearCell: 'relative text-center', + yearCellTrigger: ['relative flex w-full items-center justify-center rounded-md whitespace-nowrap tabular-nums focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition'] }, variants: { color: { ...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, { headCell: `text-${color}`, - cellTrigger: `focus-visible:ring-${color}` + cellTrigger: `focus-visible:ring-${color}`, + monthCellTrigger: `focus-visible:ring-${color}`, + yearCellTrigger: `focus-visible:ring-${color}` }])), neutral: { headCell: 'text-highlighted', - cellTrigger: 'focus-visible:ring-inverted' + cellTrigger: 'focus-visible:ring-inverted', + monthCellTrigger: 'focus-visible:ring-inverted', + yearCellTrigger: 'focus-visible:ring-inverted' } }, variant: { @@ -41,7 +81,9 @@ export default (options: Required) => ({ headCell: 'text-[10px]', headCellWeek: 'text-[10px]', cellTrigger: 'size-7', - body: 'space-y-2 pt-2' + body: 'space-y-2 pt-2', + monthCellTrigger: 'h-7 px-2 text-xs', + yearCellTrigger: 'h-7 px-2 text-xs' }, sm: { heading: 'text-xs', @@ -49,7 +91,9 @@ export default (options: Required) => ({ headCellWeek: 'text-xs', cellWeek: 'text-xs', cell: 'text-xs', - cellTrigger: 'size-7' + cellTrigger: 'size-7', + monthCellTrigger: 'h-7 px-2 text-xs', + yearCellTrigger: 'h-7 px-2 text-xs' }, md: { heading: 'text-sm', @@ -57,19 +101,25 @@ export default (options: Required) => ({ headCellWeek: 'text-xs', cellWeek: 'text-xs', cell: 'text-sm', - cellTrigger: 'size-8' + cellTrigger: 'size-8', + monthCellTrigger: 'h-8 px-3 text-sm', + yearCellTrigger: 'h-8 px-3 text-sm' }, lg: { heading: 'text-md', headCell: 'text-md', headCellWeek: 'text-md', - cellTrigger: 'size-9 text-md' + cellTrigger: 'size-9 text-md', + monthCellTrigger: 'h-9 px-4 text-md', + yearCellTrigger: 'h-9 px-4 text-md' }, xl: { heading: 'text-lg', headCell: 'text-lg', headCellWeek: 'text-lg', - cellTrigger: 'size-10 text-lg' + cellTrigger: 'size-10 text-lg', + monthCellTrigger: 'h-10 px-5 text-lg', + yearCellTrigger: 'h-10 px-5 text-lg' } }, weekNumbers: { @@ -79,55 +129,80 @@ export default (options: Required) => ({ } } }, - compoundVariants: [...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'solid', - class: { - cellTrigger: `data-[selected]:bg-${color} data-[selected]:text-inverted data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` - } - })), ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'outline', - class: { - cellTrigger: `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` - } - })), ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'soft', - class: { - cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` - } - })), ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'subtle', - class: { - cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25 data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` - } - })), { - color: 'neutral', - variant: 'solid', - class: { - cellTrigger: 'data-[selected]:bg-inverted data-[selected]:text-inverted data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' - } - }, { - color: 'neutral', - variant: 'outline', - class: { - cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10' - } - }, { - color: 'neutral', - variant: 'soft', - class: { - cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' - } - }, { - color: 'neutral', - variant: 'subtle', - class: { - cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + compoundVariants: [ + ...(options.theme.colors || []).map((color: string) => ({ + color, + variant: 'solid', + class: { + cellTrigger: `data-[selected]:bg-${color} data-[selected]:text-inverted data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + } + })), + ...(options.theme.colors || []).map((color: string) => ({ + color, + variant: 'outline', + class: { + cellTrigger: `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` + } + })), + ...(options.theme.colors || []).map((color: string) => ({ + color, + variant: 'soft', + class: { + cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + } + })), + ...(options.theme.colors || []).map((color: string) => ({ + color, + variant: 'subtle', + class: { + cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25 data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + } + })), + ...(options.theme.colors || []).flatMap((color: string) => (['solid', 'outline', 'soft', 'subtle'] as PickerTriggerVariant[]).map(variant => ({ + color, + variant, + class: { + monthCellTrigger: getPickerTriggerClass(color, variant), + yearCellTrigger: getPickerTriggerClass(color, variant) + } + }))), + { + color: 'neutral', + variant: 'solid', + class: { + cellTrigger: 'data-[selected]:bg-inverted data-[selected]:text-inverted data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', + monthCellTrigger: getNeutralPickerTriggerClass('solid'), + yearCellTrigger: getNeutralPickerTriggerClass('solid') + } + }, + { + color: 'neutral', + variant: 'outline', + class: { + cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10', + monthCellTrigger: getNeutralPickerTriggerClass('outline'), + yearCellTrigger: getNeutralPickerTriggerClass('outline') + } + }, + { + color: 'neutral', + variant: 'soft', + class: { + cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', + monthCellTrigger: getNeutralPickerTriggerClass('soft'), + yearCellTrigger: getNeutralPickerTriggerClass('soft') + } + }, + { + color: 'neutral', + variant: 'subtle', + class: { + cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', + monthCellTrigger: getNeutralPickerTriggerClass('subtle'), + yearCellTrigger: getNeutralPickerTriggerClass('subtle') + } } - }], + ], defaultVariants: { size: 'md', color: 'primary', diff --git a/test/components/Calendar.spec.ts b/test/components/Calendar.spec.ts index 4862b06030..c1f2d34321 100644 --- a/test/components/Calendar.spec.ts +++ b/test/components/Calendar.spec.ts @@ -1,10 +1,19 @@ -import { describe, it, expect, vi, afterAll, test } from 'vitest' +import { afterAll, describe, expect, it, test, vi } from 'vitest' import { axe } from 'vitest-axe' import { mountSuspended } from '@nuxt/test-utils/runtime' import { CalendarDate } from '@internationalized/date' -import { renderEach } from '../component-render' +import { ref } from 'vue' +import type { Locale } from '../../src/runtime/types/locale' import Calendar from '../../src/runtime/components/Calendar.vue' +import type { CalendarSlots } from '../../src/runtime/components/Calendar.vue' +import { renderEach } from '../component-render' import theme from '#build/ui/calendar' +import en from '../../src/runtime/locale/en' +import fr from '../../src/runtime/locale/fr' + +type HeadingSlotProps = Parameters>[0] +type MonthCellSlotProps = Parameters>[0] +type YearCellSlotProps = Parameters>[0] describe('Calendar', () => { const sizes = Object.keys(theme.variants.size) as any @@ -18,7 +27,6 @@ describe('Calendar', () => { }) renderEach(Calendar, [ - // Props ['with modelValue', { props: { modelValue: new CalendarDate(2025, 1, 1) } }], ['with default value', { props: { defaultValue: new CalendarDate(2025, 1, 1) } }], ['with range', { props: { range: true } }], @@ -46,7 +54,6 @@ describe('Calendar', () => { ['with as', { props: { as: 'section' } }], ['with class', { props: { class: 'max-w-sm' } }], ['with ui', { props: { ui: { header: 'gap-4' } } }], - // Slots ['with heading slot', { slots: { heading: () => 'Heading' } }], ['with day slot', { slots: { day: ({ day }) => day.day } }], ['with week-day slot', { slots: { 'week-day': ({ day }) => day } }] @@ -55,18 +62,18 @@ describe('Calendar', () => { describe('emits', () => { test('update:modelValue event', async () => { const wrapper = await mountSuspended(Calendar) - const date = new CalendarDate(2025, 1, 1) + const value = new CalendarDate(2025, 1, 1) - await wrapper.setValue(date) - expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[date]] }) + await wrapper.setValue(value) + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[value]] }) }) test('update:modelValue event range', async () => { const wrapper = await mountSuspended(Calendar, { props: { range: true } }) - const date = { start: new CalendarDate(2025, 1, 1), end: new CalendarDate(2025, 1, 2) } + const value = { start: new CalendarDate(2025, 1, 1), end: new CalendarDate(2025, 1, 2) } - await wrapper.setValue(date) - expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[date]] }) + await wrapper.setValue(value) + expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[value]] }) }) }) @@ -82,4 +89,253 @@ describe('Calendar', () => { expect(await axe(wrapper.element)).toHaveNoViolations() }) + + describe('type prop', () => { + test('type="month" emits update:modelValue on month select', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + type: 'month', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + const monthButtons = wrapper.findAll('[data-slot="monthCellTrigger"]') + expect(monthButtons).toHaveLength(12) + + await monthButtons[5]!.trigger('click') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted?.[0]?.[0]).toMatchObject({ month: 6 }) + }) + + test('type="year" emits update:modelValue on year select', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + type: 'year', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + const yearButtons = wrapper.findAll('[data-slot="yearCellTrigger"]') + expect(yearButtons).toHaveLength(12) + + await yearButtons[0]!.trigger('click') + + const emitted = wrapper.emitted('update:modelValue') + expect(emitted?.[0]?.[0]).toMatchObject({ year: 2020 }) + }) + + test('placeholder falls back to modelValue before today for month and year pickers', async () => { + let monthDate: CalendarDate | null = null + let yearDate: CalendarDate | null = null + + await mountSuspended(Calendar, { + props: { + type: 'month', + modelValue: new CalendarDate(2023, 7, 1) + }, + slots: { + heading: ({ date }: HeadingSlotProps) => { + monthDate = date as CalendarDate + return 'heading' + } + } + }) + + await mountSuspended(Calendar, { + props: { + type: 'year', + defaultValue: new CalendarDate(2032, 1, 1) + }, + slots: { + heading: ({ date }: HeadingSlotProps) => { + yearDate = date as CalendarDate + return 'heading' + } + } + }) + + expect(monthDate).toMatchObject({ year: 2023, month: 7 }) + expect(yearDate).toMatchObject({ year: 2032 }) + }) + + test('month-cell and year-cell slots receive picker data', async () => { + const monthWrapper = await mountSuspended(Calendar, { + props: { type: 'month', defaultValue: new CalendarDate(2025, 1, 1) }, + slots: { + 'month-cell': ({ month }: MonthCellSlotProps) => `M${month.month}` + } + }) + + const yearWrapper = await mountSuspended(Calendar, { + props: { type: 'year', defaultValue: new CalendarDate(2025, 1, 1) }, + slots: { + 'year-cell': ({ year }: YearCellSlotProps) => `Y${year.year}` + } + }) + + expect(monthWrapper.text()).toContain('M1') + expect(yearWrapper.text()).toContain('Y2020') + }) + }) + + describe('view switching', () => { + test('defaultView renders month and year panels', async () => { + const monthWrapper = await mountSuspended(Calendar, { + props: { + defaultView: 'month', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + const yearWrapper = await mountSuspended(Calendar, { + props: { + defaultView: 'year', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + expect(monthWrapper.find('[data-slot="monthGrid"]').exists()).toBe(true) + expect(monthWrapper.find('[data-slot="grid"]').exists()).toBe(false) + expect(yearWrapper.find('[data-slot="yearGrid"]').exists()).toBe(true) + expect(yearWrapper.find('[data-slot="grid"]').exists()).toBe(false) + }) + + test('clicking the heading switches day -> month -> year', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { defaultValue: new CalendarDate(2025, 1, 1) } + }) + + expect(wrapper.find('[data-slot="grid"]').exists()).toBe(true) + + const dayButtons = wrapper.findAll('[data-slot="heading"] button') + await dayButtons[0]!.trigger('click') + + expect(wrapper.find('[data-slot="monthGrid"]').exists()).toBe(true) + + const monthButtons = wrapper.findAll('[data-slot="heading"] button') + await monthButtons[1]!.trigger('click') + + expect(wrapper.find('[data-slot="yearGrid"]').exists()).toBe(true) + }) + + test('selecting a month returns to day view and emits update:placeholder once', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + defaultView: 'month', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + const monthButtons = wrapper.findAll('[data-slot="monthCellTrigger"]') + await monthButtons[5]!.trigger('click') + + expect(wrapper.find('[data-slot="grid"]').exists()).toBe(true) + + const placeholderEvents = wrapper.emitted('update:placeholder') ?? [] + expect(placeholderEvents).toHaveLength(1) + expect(placeholderEvents[0]?.[0]).toMatchObject({ month: 6 }) + }) + + test('selecting a year returns to month view and emits update:placeholder once', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + defaultView: 'year', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + const yearButtons = wrapper.findAll('[data-slot="yearCellTrigger"]') + await yearButtons[0]!.trigger('click') + + expect(wrapper.find('[data-slot="monthGrid"]').exists()).toBe(true) + + const placeholderEvents = wrapper.emitted('update:placeholder') ?? [] + expect(placeholderEvents).toHaveLength(1) + expect(placeholderEvents[0]?.[0]).toMatchObject({ year: 2020, month: 1 }) + }) + + test('day month navigation emits update:placeholder once per action', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + defaultValue: new CalendarDate(2025, 1, 1), + yearControls: false + } + }) + + await wrapper.find('[aria-label="Next month"]').trigger('click') + + const placeholderEvents = wrapper.emitted('update:placeholder') ?? [] + expect(placeholderEvents).toHaveLength(1) + }) + + test('emits update:view while switching views', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { defaultValue: new CalendarDate(2025, 1, 1) } + }) + + const headingButtons = wrapper.findAll('[data-slot="heading"] button') + await headingButtons[0]!.trigger('click') + + expect(wrapper.emitted('update:view')?.[0]).toEqual(['month']) + }) + + test('type="month" keeps the standalone picker heading static', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + type: 'month', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + expect(wrapper.find('[data-slot="monthGrid"]').exists()).toBe(true) + expect(wrapper.find('[data-slot="heading"] button').exists()).toBe(false) + }) + }) + + describe('labels', () => { + test('uses fallback text for switch aria labels when locale keys are missing', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { defaultValue: new CalendarDate(2025, 1, 1) } + }) + + const headingButtons = wrapper.findAll('[data-slot="heading"] button') + expect(headingButtons[0]!.attributes('aria-label')).toContain('Switch to month view') + expect(headingButtons[1]!.attributes('aria-label')).toContain('Switch to year view') + }) + + test('updates formatter output when locale changes', async () => { + vi.resetModules() + + const { default: AppComponent } = await import('../../src/runtime/components/App.vue') + const { default: CalendarComponent } = await import('../../src/runtime/components/Calendar.vue') + const locale = ref>(en) + const wrapper = await mountSuspended({ + components: { + UApp: AppComponent, + UCalendar: CalendarComponent + }, + setup() { + return { + locale, + CalendarDate + } + }, + template: ` + + + + ` + }) + + const getMonthLabel = () => wrapper.findAll('[data-slot="heading"] button')[0]!.text() + + expect(getMonthLabel()).toBe('January') + + locale.value = fr as Locale + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(getMonthLabel().toLowerCase()).toBe('janvier') + }) + }) }) diff --git a/test/components/__snapshots__/Calendar-vue.spec.ts.snap b/test/components/__snapshots__/Calendar-vue.spec.ts.snap index ca7a505267..ae32b105bc 100644 --- a/test/components/__snapshots__/Calendar-vue.spec.ts.snap +++ b/test/components/__snapshots__/Calendar-vue.spec.ts.snap @@ -9,7 +9,9 @@ exports[`Calendar > renders with as correctly 1`] = ` -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
Heading
-
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January - February 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+
+
+
-
January 2025
+ diff --git a/test/components/__snapshots__/Calendar.spec.ts.snap b/test/components/__snapshots__/Calendar.spec.ts.snap index 61ad59cc42..6f51a54ee0 100644 --- a/test/components/__snapshots__/Calendar.spec.ts.snap +++ b/test/components/__snapshots__/Calendar.spec.ts.snap @@ -9,7 +9,9 @@ exports[`Calendar > renders with as correctly 1`] = ` -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
Heading
-
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January - February 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+ -
January 2025
+
+
+
-
January 2025
+ From 0f082e990efaf8ccd3b3532b964708413bc70dba Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 6 Mar 2026 14:31:21 +0100 Subject: [PATCH 2/6] fix(calendar): update formatter locale in place --- src/runtime/components/Calendar.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index 368a599913..62b8a03212 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -208,7 +208,9 @@ const uiProp = useComponentUI('calendar', props) const formatter = shallowRef(useDateFormatter(code.value)) watch(() => code.value, (value) => { - formatter.value = useDateFormatter(value) + if (formatter.value.getLocale() !== value) { + formatter.value.setLocale(value) + } }) const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.calendar || {}) })({ @@ -360,6 +362,8 @@ const switchToMonthsLabel = computed(() => translateWithFallback('calendar.switc const switchToYearsLabel = computed(() => translateWithFallback('calendar.switchToYears', 'Switch to year view')) function formatMonthLabel(date: DateValue) { + code.value + try { return formatter.value.custom(date.toDate(getLocalTimeZone()), { month: 'long' }) } catch { @@ -368,6 +372,8 @@ function formatMonthLabel(date: DateValue) { } function formatYearLabel(date: DateValue) { + code.value + try { return formatter.value.custom(date.toDate(getLocalTimeZone()), { year: 'numeric' }) } catch { From 1c7f1eeda143ca96f46698d58a1dfb434d229bd3 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 6 Mar 2026 20:35:15 +0100 Subject: [PATCH 3/6] fix(Calendar): preserve picker root props and view state --- src/runtime/components/Calendar.vue | 35 +++++++++++++++-------------- test/components/Calendar.spec.ts | 29 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index 62b8a03212..c5cf7d0187 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -238,9 +238,14 @@ function getDefaultView(type: CalendarType = props.type, defaultView = props.def const internalView = ref(getDefaultView()) -watch(() => [props.type, props.defaultView, props.view], ([type, defaultView, view]) => { - if (view === undefined) { - internalView.value = getDefaultView(type as CalendarType, defaultView as CalendarView | undefined) +watch(() => [props.type, props.view] as const, ([type, view], [previousType]) => { + if (view !== undefined) { + internalView.value = view + return + } + + if (type !== previousType) { + internalView.value = getDefaultView(type as CalendarType, props.defaultView) } }) @@ -362,8 +367,6 @@ const switchToMonthsLabel = computed(() => translateWithFallback('calendar.switc const switchToYearsLabel = computed(() => translateWithFallback('calendar.switchToYears', 'Switch to year view')) function formatMonthLabel(date: DateValue) { - code.value - try { return formatter.value.custom(date.toDate(getLocalTimeZone()), { month: 'long' }) } catch { @@ -372,8 +375,6 @@ function formatMonthLabel(date: DateValue) { } function formatYearLabel(date: DateValue) { - code.value - try { return formatter.value.custom(date.toDate(getLocalTimeZone()), { year: 'numeric' }) } catch { @@ -488,6 +489,15 @@ const pickerValueProps = computed>(() => isStandalonePicker. defaultValue: pickerDefaultValue.value } : {}) +const pickerRootProps = computed(() => ({ + ...calendarRootProps.value, + ...pickerValueProps.value, + 'placeholder': pickerPlaceholder.value, + 'locale': code.value, + 'dir': dir.value, + 'onUpdate:modelValue': picker.value.onUpdate, + 'onUpdate:placeholder': onPickerPlaceholderUpdate +})) function onPickerPlaceholderUpdate(value: DateValue) { if (isStandalonePicker.value) { @@ -620,18 +630,9 @@ function onPickerPlaceholderUpdate(value: DateValue) { :is="picker.root" v-else v-slot="{ grid, date }" - v-bind="pickerValueProps" - :placeholder="pickerPlaceholder" - :locale="code" - :dir="dir" - :min-value="minValue" - :max-value="maxValue" - :disabled="disabled" - :readonly="readonly" + v-bind="pickerRootProps" data-slot="root" :class="ui.root({ class: ['inline-flex w-fit flex-col gap-4', uiProp?.root, props.class] })" - @update:model-value="picker.onUpdate" - @update:placeholder="onPickerPlaceholderUpdate" > { expect(wrapper.emitted('update:view')?.[0]).toEqual(['month']) }) + test('changing defaultView after mount does not reset uncontrolled view', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { defaultValue: new CalendarDate(2025, 1, 1) } + }) + + const headingButtons = wrapper.findAll('[data-slot="heading"] button') + await headingButtons[0]!.trigger('click') + await wrapper.findAll('[data-slot="heading"] button')[1]!.trigger('click') + + expect(wrapper.find('[data-slot="yearGrid"]').exists()).toBe(true) + + await wrapper.setProps({ defaultView: 'month' }) + + expect(wrapper.find('[data-slot="yearGrid"]').exists()).toBe(true) + expect(wrapper.find('[data-slot="monthGrid"]').exists()).toBe(false) + }) + test('type="month" keeps the standalone picker heading static', async () => { const wrapper = await mountSuspended(Calendar, { props: { @@ -290,6 +307,18 @@ describe('Calendar', () => { expect(wrapper.find('[data-slot="monthGrid"]').exists()).toBe(true) expect(wrapper.find('[data-slot="heading"] button').exists()).toBe(false) }) + + test('type="month" forwards root props to the picker root', async () => { + const wrapper = await mountSuspended(Calendar, { + props: { + type: 'month', + as: 'section', + defaultValue: new CalendarDate(2025, 1, 1) + } + }) + + expect(wrapper.find('[data-slot="root"]').element.tagName).toBe('SECTION') + }) }) describe('labels', () => { From 6deceb848aabbfd6ff2a3cd2583b7573fcb702c7 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 7 Apr 2026 16:02:33 +0200 Subject: [PATCH 4/6] fix(Calendar): use namespaced month and year pickers --- src/runtime/components/Calendar.vue | 92 ++++++------------- .../__snapshots__/Calendar-vue.spec.ts.snap | 8 +- .../__snapshots__/Calendar.spec.ts.snap | 8 +- 3 files changed, 40 insertions(+), 68 deletions(-) diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index c5cf7d0187..221bd4f90b 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -141,51 +141,15 @@ export interface CalendarSlots { - - diff --git a/docs/content/docs/2.components/calendar.md b/docs/content/docs/2.components/calendar.md index 7f87fc6fbc..6f4dad2a00 100644 --- a/docs/content/docs/2.components/calendar.md +++ b/docs/content/docs/2.components/calendar.md @@ -344,16 +344,6 @@ name: 'calendar-month-picker-example' --- :: -### As a year picker - -Use `type="year"` with a [Popover](/docs/components/popover) to create a year picker. - -::component-example ---- -name: 'calendar-year-picker-example' ---- -:: - ### As a date picker Use a [Button](/docs/components/button) and a [Popover](/docs/components/popover) component to create a date picker. diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index 221bd4f90b..1c859085de 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -177,16 +177,9 @@ watch(() => code.value, (value) => { } }) -const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.calendar || {}) })({ - color: props.color, - size: props.size, - variant: props.variant, - weekNumbers: props.weekNumbers -})) - -function getDefaultView(type: CalendarType = props.type, defaultView = props.defaultView) { - if (defaultView) { - return defaultView +function getDefaultView(type: CalendarType = props.type) { + if (props.defaultView) { + return props.defaultView } if (type === 'month') { @@ -209,7 +202,7 @@ watch(() => [props.type, props.view] as const, ([type, view], [previousType]) => } if (type !== previousType) { - internalView.value = getDefaultView(type as CalendarType, props.defaultView) + internalView.value = getDefaultView(type as CalendarType) } }) @@ -235,6 +228,14 @@ const view = computed({ } }) +const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.calendar || {}) })({ + color: props.color, + size: props.size, + variant: props.variant, + weekNumbers: props.weekNumbers, + type: view.value +})) + function resolveDateValue(value: DateValue | DateValue[] | DateRange | null | undefined) { if (Array.isArray(value)) { return value[0] @@ -259,14 +260,6 @@ const localPlaceholder = shallowRef(props.placeholder) watch(() => props.placeholder, (value) => { localPlaceholder.value = value -}, { immediate: true }) - -const dayPlaceholder = computed(() => { - if (props.placeholder !== undefined || localPlaceholder.value !== undefined) { - return localPlaceholder.value - } - - return undefined }) const pickerPlaceholder = computed(() => { @@ -316,43 +309,30 @@ const nextMonthIcon = computed(() => props.nextMonthIcon || (dir.value === 'rtl' const prevYearIcon = computed(() => props.prevYearIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronDoubleRight : appConfig.ui.icons.chevronDoubleLeft)) const prevMonthIcon = computed(() => props.prevMonthIcon || (dir.value === 'rtl' ? appConfig.ui.icons.chevronRight : appConfig.ui.icons.chevronLeft)) -const translateWithFallback = (key: string, fallback: string) => { - const label = t(key) - return label === key ? fallback : label -} - -const prevMonthLabel = computed(() => translateWithFallback('calendar.prevMonth', 'Previous month')) -const nextMonthLabel = computed(() => translateWithFallback('calendar.nextMonth', 'Next month')) -const prevYearLabel = computed(() => translateWithFallback('calendar.prevYear', 'Previous year')) -const nextYearLabel = computed(() => translateWithFallback('calendar.nextYear', 'Next year')) -const prevDecadeLabel = computed(() => translateWithFallback('calendar.prevDecade', prevYearLabel.value)) -const nextDecadeLabel = computed(() => translateWithFallback('calendar.nextDecade', nextYearLabel.value)) -const switchToMonthsLabel = computed(() => translateWithFallback('calendar.switchToMonths', 'Switch to month view')) -const switchToYearsLabel = computed(() => translateWithFallback('calendar.switchToYears', 'Switch to year view')) - -function formatMonthLabel(date: DateValue) { - try { - return formatter.value.custom(date.toDate(getLocalTimeZone()), { month: 'long' }) - } catch { - return String(date.month) - } -} +const prevMonthLabel = computed(() => t('calendar.prevMonth')) +const nextMonthLabel = computed(() => t('calendar.nextMonth')) +const prevYearLabel = computed(() => t('calendar.prevYear')) +const nextYearLabel = computed(() => t('calendar.nextYear')) +const prevDecadeLabel = computed(() => t('calendar.prevDecade')) +const nextDecadeLabel = computed(() => t('calendar.nextDecade')) +const switchToMonthsLabel = computed(() => t('calendar.switchToMonths')) +const switchToYearsLabel = computed(() => t('calendar.switchToYears')) -function formatYearLabel(date: DateValue) { +function formatDatePart(date: DateValue, options: Intl.DateTimeFormatOptions, fallback: number) { try { - return formatter.value.custom(date.toDate(getLocalTimeZone()), { year: 'numeric' }) + return formatter.value.custom(date.toDate(getLocalTimeZone()), options) } catch { - return String(date.year) + return String(fallback) } } -function getHeadingValue(date: DateValue, value: Extract) { - return value === 'month' ? formatMonthLabel(date) : formatYearLabel(date) -} +const formatMonthLabel = (date: DateValue) => formatDatePart(date, { month: 'long' }, date.month) +const formatYearLabel = (date: DateValue) => formatDatePart(date, { year: 'numeric' }, date.year) function getHeadingLabel(date: DateValue, value: Extract) { + const formatted = value === 'month' ? formatMonthLabel(date) : formatYearLabel(date) const actionLabel = value === 'month' ? switchToMonthsLabel.value : switchToYearsLabel.value - return `${getHeadingValue(date, value)}, ${actionLabel}` + return `${formatted}, ${actionLabel}` } function getHeadingButtonClass(active: boolean) { @@ -368,9 +348,8 @@ function emitModelValue(value: DateValue | DateRange) { const DayCalendar = computed(() => props.range ? RangeCalendar : SingleCalendar) const isMonthView = computed(() => view.value === 'month') -const isYearView = computed(() => view.value === 'year') const isNavigationMonthView = computed(() => props.type === 'date' && isMonthView.value) -const isNavigationYearView = computed(() => props.type === 'date' && isYearView.value) +const isNavigationYearView = computed(() => props.type === 'date' && view.value === 'year') const isStandalonePicker = computed(() => props.type !== 'date') const showMonthNavigation = computed(() => props.type === 'date' && view.value === 'day' && props.monthControls) @@ -396,53 +375,28 @@ function onYearUpdate(value: DateValue | DateRange) { emitModelValue(value) } -const monthPicker = computed(() => ({ - kind: 'month' as const, - root: props.range ? MonthRangePicker.Root : MonthPicker.Root, - header: props.range ? MonthRangePicker.Header : MonthPicker.Header, - heading: props.range ? MonthRangePicker.Heading : MonthPicker.Heading, - grid: props.range ? MonthRangePicker.Grid : MonthPicker.Grid, - gridBody: props.range ? MonthRangePicker.GridBody : MonthPicker.GridBody, - gridRow: props.range ? MonthRangePicker.GridRow : MonthPicker.GridRow, - cell: props.range ? MonthRangePicker.Cell : MonthPicker.Cell, - cellTrigger: props.range ? MonthRangePicker.CellTrigger : MonthPicker.CellTrigger, - prev: props.range ? MonthRangePicker.Prev : MonthPicker.Prev, - next: props.range ? MonthRangePicker.Next : MonthPicker.Next, - slotName: 'month-cell', - gridSlot: 'monthGrid', - rowSlot: 'monthGridRow', - cellSlot: 'monthCell', - triggerSlot: 'monthCellTrigger', - itemProp: 'month', - labelProp: 'monthValue', - previousLabel: prevYearLabel.value, - nextLabel: nextYearLabel.value, - onUpdate: onMonthUpdate -}) as const) - -const yearPicker = computed(() => ({ - kind: 'year' as const, - root: props.range ? YearRangePicker.Root : YearPicker.Root, - header: props.range ? YearRangePicker.Header : YearPicker.Header, - heading: props.range ? YearRangePicker.Heading : YearPicker.Heading, - grid: props.range ? YearRangePicker.Grid : YearPicker.Grid, - gridBody: props.range ? YearRangePicker.GridBody : YearPicker.GridBody, - gridRow: props.range ? YearRangePicker.GridRow : YearPicker.GridRow, - cell: props.range ? YearRangePicker.Cell : YearPicker.Cell, - cellTrigger: props.range ? YearRangePicker.CellTrigger : YearPicker.CellTrigger, - prev: props.range ? YearRangePicker.Prev : YearPicker.Prev, - next: props.range ? YearRangePicker.Next : YearPicker.Next, - slotName: 'year-cell', - gridSlot: 'yearGrid', - rowSlot: 'yearGridRow', - cellSlot: 'yearCell', - triggerSlot: 'yearCellTrigger', - itemProp: 'year', - labelProp: 'yearValue', - previousLabel: prevDecadeLabel.value, - nextLabel: nextDecadeLabel.value, - onUpdate: onYearUpdate -}) as const) +function createPickerConfig(kind: 'month' | 'year') { + const ns = kind === 'month' + ? { single: MonthPicker, range: MonthRangePicker } + : { single: YearPicker, range: YearRangePicker } + const source = props.range ? ns.range : ns.single + return { + kind, + root: source.Root, header: source.Header, heading: source.Heading, + grid: source.Grid, gridBody: source.GridBody, gridRow: source.GridRow, + cell: source.Cell, cellTrigger: source.CellTrigger, + prev: source.Prev, next: source.Next, + slotName: `${kind}-cell`, + itemProp: kind, + labelProp: `${kind}Value`, + previousLabel: kind === 'month' ? prevYearLabel.value : prevDecadeLabel.value, + nextLabel: kind === 'month' ? nextYearLabel.value : nextDecadeLabel.value, + onUpdate: kind === 'month' ? onMonthUpdate : onYearUpdate + } as const +} + +const monthPicker = computed(() => createPickerConfig('month')) +const yearPicker = computed(() => createPickerConfig('year')) const picker = computed(() => isMonthView.value ? monthPicker.value : yearPicker.value) const pickerDefaultValue = computed(() => props.defaultValue as DateValue | DateRange | undefined) @@ -480,7 +434,7 @@ function onPickerPlaceholderUpdate(value: DateValue) { v-bind="calendarRootProps" :model-value="(modelValue as DateValue | DateValue[])" :default-value="(defaultValue as DateValue)" - :placeholder="dayPlaceholder" + :placeholder="localPlaceholder" :locale="code" :dir="dir" data-slot="root" @@ -654,10 +608,8 @@ function onPickerPlaceholderUpdate(value: DateValue) { {{ slotProps.monthValue }} diff --git a/src/runtime/locale/en.ts b/src/runtime/locale/en.ts index 7eced47986..4bd6144b75 100644 --- a/src/runtime/locale/en.ts +++ b/src/runtime/locale/en.ts @@ -17,10 +17,14 @@ export default defineLocale({ close: 'Close' }, calendar: { + nextDecade: 'Next decade', nextMonth: 'Next month', nextYear: 'Next year', + prevDecade: 'Previous decade', prevMonth: 'Previous month', - prevYear: 'Previous year' + prevYear: 'Previous year', + switchToMonths: 'Switch to month view', + switchToYears: 'Switch to year view' }, carousel: { dots: 'Choose slide to display', diff --git a/src/runtime/types/locale.ts b/src/runtime/types/locale.ts index de30698a20..230471924a 100644 --- a/src/runtime/types/locale.ts +++ b/src/runtime/types/locale.ts @@ -11,10 +11,14 @@ export type Messages = { close: string } calendar: { + nextDecade?: string nextMonth: string nextYear: string + prevDecade?: string prevMonth: string prevYear: string + switchToMonths?: string + switchToYears?: string } carousel: { dots: string diff --git a/src/theme/calendar.ts b/src/theme/calendar.ts index a09ab88cfd..7592bbcd98 100644 --- a/src/theme/calendar.ts +++ b/src/theme/calendar.ts @@ -1,30 +1,52 @@ import type { ModuleOptions } from '../module' type PickerTriggerVariant = 'solid' | 'outline' | 'soft' | 'subtle' +type CalendarPanelType = 'day' | 'month' | 'year' -function getPickerTriggerClass(color: string, variant: PickerTriggerVariant) { +const pickerTriggerVariants = ['solid', 'outline', 'soft', 'subtle'] as const + +const triggerSizeClasses = { + day: { + xs: 'size-7', + sm: 'size-7', + md: 'size-8', + lg: 'size-9 text-md', + xl: 'size-10 text-lg' + }, + picker: { + xs: 'h-7 px-2 text-xs', + sm: 'h-7 px-2 text-xs', + md: 'h-8 px-3 text-sm', + lg: 'h-9 px-4 text-md', + xl: 'h-10 px-5 text-lg' + } +} as const + +function getTriggerClass(color: string, variant: PickerTriggerVariant, includeToday: boolean) { + const today = includeToday ? ` data-today:not-data-[selected]:text-${color}` : '' switch (variant) { case 'solid': - return `data-[selected]:bg-${color} data-[selected]:text-inverted data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + return `data-[selected]:bg-${color} data-[selected]:text-inverted${today} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` case 'outline': - return `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` + return `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color}${today} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` case 'soft': - return `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + return `data-[selected]:bg-${color}/10 data-[selected]:text-${color}${today} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` case 'subtle': - return `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25 data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + return `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25${today} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` } } -function getNeutralPickerTriggerClass(variant: PickerTriggerVariant) { +function getNeutralTriggerClass(variant: PickerTriggerVariant, includeToday: boolean) { + const today = includeToday ? ' data-today:not-data-[selected]:text-highlighted' : '' switch (variant) { case 'solid': - return 'data-[selected]:bg-inverted data-[selected]:text-inverted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + return `data-[selected]:bg-inverted data-[selected]:text-inverted${today} data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10` case 'outline': - return 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10' + return `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default${today} data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10` case 'soft': - return 'data-[selected]:bg-elevated data-[selected]:text-default data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + return `data-[selected]:bg-elevated data-[selected]:text-default${today} data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10` case 'subtle': - return 'data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10' + return `data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented${today} data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10` } } @@ -34,37 +56,25 @@ export default (options: Required) => ({ header: 'flex items-center justify-between', body: 'flex flex-col space-y-4 pt-4 sm:flex-row sm:space-x-4 sm:space-y-0', heading: 'mx-auto text-center font-medium', - grid: 'w-full border-collapse select-none space-y-1 focus:outline-none', - gridRow: 'grid grid-cols-7 place-items-center', + grid: 'w-full select-none space-y-1 focus:outline-none', + gridRow: 'grid', gridWeekDaysRow: 'mb-1 grid w-full grid-cols-7', gridBody: 'grid', headCell: 'rounded-md', headCellWeek: 'rounded-md text-muted', cell: 'relative text-center', - cellTrigger: ['m-0.5 relative flex items-center justify-center rounded-full whitespace-nowrap focus-visible:ring-2 focus:outline-none data-disabled:text-muted data-unavailable:line-through data-unavailable:text-muted data-unavailable:pointer-events-none data-today:font-semibold data-[outside-view]:text-muted', options.theme.transitions && 'transition'], - cellWeek: 'relative text-center text-muted', - monthGrid: 'w-full select-none space-y-1 focus:outline-none', - monthGridRow: 'grid grid-cols-4 gap-1', - monthCell: 'relative text-center', - monthCellTrigger: ['relative flex w-full items-center justify-center rounded-md whitespace-nowrap focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition'], - yearGrid: 'w-full select-none space-y-1 focus:outline-none', - yearGridRow: 'grid grid-cols-4 gap-1', - yearCell: 'relative text-center', - yearCellTrigger: ['relative flex w-full items-center justify-center rounded-md whitespace-nowrap tabular-nums focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition'] + cellTrigger: ['relative flex items-center justify-center whitespace-nowrap focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition'], + cellWeek: 'relative text-center text-muted' }, variants: { color: { ...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, { headCell: `text-${color}`, - cellTrigger: `focus-visible:ring-${color}`, - monthCellTrigger: `focus-visible:ring-${color}`, - yearCellTrigger: `focus-visible:ring-${color}` + cellTrigger: `focus-visible:ring-${color}` }])), neutral: { headCell: 'text-highlighted', - cellTrigger: 'focus-visible:ring-inverted', - monthCellTrigger: 'focus-visible:ring-inverted', - yearCellTrigger: 'focus-visible:ring-inverted' + cellTrigger: 'focus-visible:ring-inverted' } }, variant: { @@ -73,6 +83,21 @@ export default (options: Required) => ({ soft: '', subtle: '' }, + type: { + day: { + grid: 'border-collapse', + gridRow: 'grid-cols-7 place-items-center', + cellTrigger: 'm-0.5 rounded-full data-unavailable:line-through data-unavailable:text-muted data-unavailable:pointer-events-none data-today:font-semibold data-[outside-view]:text-muted' + }, + month: { + gridRow: 'grid-cols-4 gap-1', + cellTrigger: 'w-full rounded-md' + }, + year: { + gridRow: 'grid-cols-4 gap-1', + cellTrigger: 'w-full rounded-md tabular-nums' + } + }, size: { xs: { heading: 'text-xs', @@ -80,132 +105,93 @@ export default (options: Required) => ({ cellWeek: 'text-xs', headCell: 'text-[10px]', headCellWeek: 'text-[10px]', - cellTrigger: 'size-7', - body: 'space-y-2 pt-2', - monthCellTrigger: 'h-7 px-2 text-xs', - yearCellTrigger: 'h-7 px-2 text-xs' + body: 'space-y-2 pt-2' }, sm: { heading: 'text-xs', headCell: 'text-xs', headCellWeek: 'text-xs', cellWeek: 'text-xs', - cell: 'text-xs', - cellTrigger: 'size-7', - monthCellTrigger: 'h-7 px-2 text-xs', - yearCellTrigger: 'h-7 px-2 text-xs' + cell: 'text-xs' }, md: { heading: 'text-sm', headCell: 'text-xs', headCellWeek: 'text-xs', cellWeek: 'text-xs', - cell: 'text-sm', - cellTrigger: 'size-8', - monthCellTrigger: 'h-8 px-3 text-sm', - yearCellTrigger: 'h-8 px-3 text-sm' + cell: 'text-sm' }, lg: { heading: 'text-md', headCell: 'text-md', - headCellWeek: 'text-md', - cellTrigger: 'size-9 text-md', - monthCellTrigger: 'h-9 px-4 text-md', - yearCellTrigger: 'h-9 px-4 text-md' + headCellWeek: 'text-md' }, xl: { heading: 'text-lg', headCell: 'text-lg', - headCellWeek: 'text-lg', - cellTrigger: 'size-10 text-lg', - monthCellTrigger: 'h-10 px-5 text-lg', - yearCellTrigger: 'h-10 px-5 text-lg' + headCellWeek: 'text-lg' } }, weekNumbers: { - true: { - gridRow: 'grid-cols-8', - gridWeekDaysRow: 'grid-cols-8 [&>*:first-child]:col-start-2' - } + true: '' } }, compoundVariants: [ - ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'solid', - class: { - cellTrigger: `data-[selected]:bg-${color} data-[selected]:text-inverted data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` - } + ...Object.entries(triggerSizeClasses.day).map(([size, cellTrigger]) => ({ + size, + type: 'day' as CalendarPanelType, + class: { cellTrigger } })), - ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'outline', - class: { - cellTrigger: `data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/50 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/10 hover:not-data-[selected]:bg-${color}/10` - } + ...Object.entries(triggerSizeClasses.picker).map(([size, cellTrigger]) => ({ + size, + type: ['month', 'year'] as CalendarPanelType[], + class: { cellTrigger } })), - ...(options.theme.colors || []).map((color: string) => ({ - color, - variant: 'soft', + { + type: 'day', + weekNumbers: true, class: { - cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + gridRow: 'grid-cols-8', + gridWeekDaysRow: 'grid-cols-8 [&>*:first-child]:col-start-2' } - })), - ...(options.theme.colors || []).map((color: string) => ({ + }, + ...(options.theme.colors || []).flatMap((color: string) => pickerTriggerVariants.map(variant => ({ color, - variant: 'subtle', + variant, + type: 'day' as CalendarPanelType, class: { - cellTrigger: `data-[selected]:bg-${color}/10 data-[selected]:text-${color} data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-${color}/25 data-today:not-data-[selected]:text-${color} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + cellTrigger: getTriggerClass(color, variant, true) } - })), - ...(options.theme.colors || []).flatMap((color: string) => (['solid', 'outline', 'soft', 'subtle'] as PickerTriggerVariant[]).map(variant => ({ + }))), + ...(options.theme.colors || []).flatMap((color: string) => pickerTriggerVariants.map(variant => ({ color, variant, + type: ['month', 'year'] as CalendarPanelType[], class: { - monthCellTrigger: getPickerTriggerClass(color, variant), - yearCellTrigger: getPickerTriggerClass(color, variant) + cellTrigger: getTriggerClass(color, variant, false) } }))), - { - color: 'neutral', - variant: 'solid', - class: { - cellTrigger: 'data-[selected]:bg-inverted data-[selected]:text-inverted data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', - monthCellTrigger: getNeutralPickerTriggerClass('solid'), - yearCellTrigger: getNeutralPickerTriggerClass('solid') - } - }, - { + ...pickerTriggerVariants.map(variant => ({ color: 'neutral', - variant: 'outline', - class: { - cellTrigger: 'data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-[selected]:text-default data-[selected]:bg-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10', - monthCellTrigger: getNeutralPickerTriggerClass('outline'), - yearCellTrigger: getNeutralPickerTriggerClass('outline') - } - }, - { - color: 'neutral', - variant: 'soft', + variant, + type: 'day' as CalendarPanelType, class: { - cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', - monthCellTrigger: getNeutralPickerTriggerClass('soft'), - yearCellTrigger: getNeutralPickerTriggerClass('soft') + cellTrigger: getNeutralTriggerClass(variant, true) } - }, - { + })), + ...pickerTriggerVariants.map(variant => ({ color: 'neutral', - variant: 'subtle', + variant, + type: ['month', 'year'] as CalendarPanelType[], class: { - cellTrigger: 'data-[selected]:bg-elevated data-[selected]:text-default data-[selected]:ring data-[selected]:ring-inset data-[selected]:ring-accented data-today:not-data-[selected]:text-highlighted data-[highlighted]:bg-inverted/20 hover:not-data-[selected]:bg-inverted/10', - monthCellTrigger: getNeutralPickerTriggerClass('subtle'), - yearCellTrigger: getNeutralPickerTriggerClass('subtle') + cellTrigger: getNeutralTriggerClass(variant, false) } - } + })) ], defaultVariants: { size: 'md', color: 'primary', - variant: 'solid' + variant: 'solid', + type: 'day' } }) From 1822c677fe966b1f649bfe87b95ceea4bc26e068 Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 22 Apr 2026 09:43:43 +0200 Subject: [PATCH 6/6] fix(calendar): restore picker test contract after rebase --- src/runtime/components/Calendar.vue | 25 +- .../__snapshots__/Calendar-vue.spec.ts.snap | 3254 ++++++++--------- .../__snapshots__/Calendar.spec.ts.snap | 3254 ++++++++--------- test/nuxt/setup.ts | 45 + test/utils/setup.ts | 41 + 5 files changed, 3361 insertions(+), 3258 deletions(-) diff --git a/src/runtime/components/Calendar.vue b/src/runtime/components/Calendar.vue index 1c859085de..3caee979fd 100644 --- a/src/runtime/components/Calendar.vue +++ b/src/runtime/components/Calendar.vue @@ -399,6 +399,23 @@ const monthPicker = computed(() => createPickerConfig('month')) const yearPicker = computed(() => createPickerConfig('year')) const picker = computed(() => isMonthView.value ? monthPicker.value : yearPicker.value) +const pickerSlotNames = computed(() => { + if (picker.value.kind === 'month') { + return { + grid: 'monthGrid', + row: 'monthGridRow', + cell: 'monthCell', + trigger: 'monthCellTrigger' + } as const + } + + return { + grid: 'yearGrid', + row: 'yearGridRow', + cell: 'yearCell', + trigger: 'yearCellTrigger' + } as const +}) const pickerDefaultValue = computed(() => props.defaultValue as DateValue | DateRange | undefined) const pickerModelValue = computed(() => props.modelValue as DateValue | DateRange | undefined) const pickerValueProps = computed>(() => isStandalonePicker.value @@ -608,7 +625,7 @@ function onPickerPlaceholderUpdate(value: DateValue) { @@ -617,7 +634,7 @@ function onPickerPlaceholderUpdate(value: DateValue) { v-for="(row, rowIndex) in grid.rows" :key="rowIndex" as="div" - data-slot="gridRow" + :data-slot="pickerSlotNames.row" :class="ui.gridRow({ class: uiProp?.gridRow })" > diff --git a/test/components/__snapshots__/Calendar-vue.spec.ts.snap b/test/components/__snapshots__/Calendar-vue.spec.ts.snap index c9de48b9fd..e75d067c54 100644 --- a/test/components/__snapshots__/Calendar-vue.spec.ts.snap +++ b/test/components/__snapshots__/Calendar-vue.spec.ts.snap @@ -20,7 +20,7 @@ exports[`Calendar > renders with as correctly 1`] = `
- +
@@ -36,145 +36,145 @@ exports[`Calendar > renders with as correctly 1`] = ` @@ -206,7 +206,7 @@ exports[`Calendar > renders with class correctly 1`] = `
-
+
@@ -222,145 +222,145 @@ exports[`Calendar > renders with class correctly 1`] = ` @@ -392,7 +392,7 @@ exports[`Calendar > renders with color neutral correctly 1`] = `
-
+
@@ -408,145 +408,145 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` @@ -578,7 +578,7 @@ exports[`Calendar > renders with day slot correctly 1`] = `
-
+
@@ -594,145 +594,145 @@ exports[`Calendar > renders with day slot correctly 1`] = ` @@ -764,7 +764,7 @@ exports[`Calendar > renders with default value correctly 1`] = `
-
+
@@ -780,145 +780,145 @@ exports[`Calendar > renders with default value correctly 1`] = ` @@ -950,7 +950,7 @@ exports[`Calendar > renders with disabled correctly 1`] = `
-
+
@@ -966,145 +966,145 @@ exports[`Calendar > renders with disabled correctly 1`] = ` @@ -1134,7 +1134,7 @@ exports[`Calendar > renders with heading slot correctly 1`] = `
-
+
@@ -1150,145 +1150,145 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` @@ -1320,7 +1320,7 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = `
-
+
@@ -1336,145 +1336,145 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` @@ -1506,7 +1506,7 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = `
-
+
@@ -1522,145 +1522,145 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` @@ -1692,7 +1692,7 @@ exports[`Calendar > renders with modelValue correctly 1`] = `
-
+
@@ -1708,145 +1708,145 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` @@ -1878,7 +1878,7 @@ exports[`Calendar > renders with multiple correctly 1`] = `
-
+
@@ -1894,145 +1894,145 @@ exports[`Calendar > renders with multiple correctly 1`] = ` @@ -2064,7 +2064,7 @@ exports[`Calendar > renders with nextMonth correctly 1`] = `
-
+
@@ -2080,145 +2080,145 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` @@ -2250,7 +2250,7 @@ exports[`Calendar > renders with nextYear correctly 1`] = `
-
+
@@ -2266,145 +2266,145 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` @@ -2436,7 +2436,7 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
-
+
@@ -2452,150 +2452,150 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
- +
@@ -2611,145 +2611,145 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` @@ -2781,7 +2781,7 @@ exports[`Calendar > renders with prevMonth correctly 1`] = `
-
+
@@ -2797,145 +2797,145 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` @@ -2967,7 +2967,7 @@ exports[`Calendar > renders with prevYear correctly 1`] = `
-
+
@@ -2983,145 +2983,145 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` @@ -3156,7 +3156,7 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = `
-
+
@@ -3172,145 +3172,145 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` @@ -3342,7 +3342,7 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = `
-
+
@@ -3358,145 +3358,145 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` @@ -3528,7 +3528,7 @@ exports[`Calendar > renders with range correctly 1`] = `
-
+
@@ -3544,145 +3544,145 @@ exports[`Calendar > renders with range correctly 1`] = ` @@ -3711,7 +3711,7 @@ exports[`Calendar > renders with readonly correctly 1`] = `
-
+
@@ -3727,145 +3727,145 @@ exports[`Calendar > renders with readonly correctly 1`] = ` @@ -3897,7 +3897,7 @@ exports[`Calendar > renders with size lg correctly 1`] = `
-
+
@@ -3913,145 +3913,145 @@ exports[`Calendar > renders with size lg correctly 1`] = ` @@ -4083,7 +4083,7 @@ exports[`Calendar > renders with size md correctly 1`] = `
-
+
@@ -4099,145 +4099,145 @@ exports[`Calendar > renders with size md correctly 1`] = ` @@ -4269,7 +4269,7 @@ exports[`Calendar > renders with size sm correctly 1`] = `
-
+
@@ -4285,145 +4285,145 @@ exports[`Calendar > renders with size sm correctly 1`] = ` @@ -4455,7 +4455,7 @@ exports[`Calendar > renders with size xl correctly 1`] = `
-
+
@@ -4471,145 +4471,145 @@ exports[`Calendar > renders with size xl correctly 1`] = ` @@ -4641,7 +4641,7 @@ exports[`Calendar > renders with size xs correctly 1`] = `
-
+
@@ -4657,145 +4657,145 @@ exports[`Calendar > renders with size xs correctly 1`] = ` @@ -4827,7 +4827,7 @@ exports[`Calendar > renders with ui correctly 1`] = `
-
+
@@ -4843,145 +4843,145 @@ exports[`Calendar > renders with ui correctly 1`] = ` @@ -5013,7 +5013,7 @@ exports[`Calendar > renders with variant outline correctly 1`] = `
-
+
@@ -5029,145 +5029,145 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` @@ -5199,7 +5199,7 @@ exports[`Calendar > renders with variant soft correctly 1`] = `
-
+
@@ -5215,145 +5215,145 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` @@ -5385,7 +5385,7 @@ exports[`Calendar > renders with variant solid correctly 1`] = `
-
+
@@ -5401,145 +5401,145 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` @@ -5571,7 +5571,7 @@ exports[`Calendar > renders with variant subtle correctly 1`] = `
-
+
@@ -5587,145 +5587,145 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` @@ -5757,7 +5757,7 @@ exports[`Calendar > renders with week-day slot correctly 1`] = `
-
+
@@ -5773,145 +5773,145 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` @@ -5943,7 +5943,7 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = `
-
+
@@ -5959,145 +5959,145 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` @@ -6129,7 +6129,7 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = `
-
+
@@ -6145,145 +6145,145 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` @@ -6315,7 +6315,7 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = `
-
+
@@ -6331,145 +6331,145 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` @@ -6501,7 +6501,7 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = `
-
+
@@ -6517,121 +6517,121 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` @@ -6659,7 +6659,7 @@ exports[`Calendar > renders without monthControls correctly 1`] = `
-
+
@@ -6675,145 +6675,145 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` @@ -6841,7 +6841,7 @@ exports[`Calendar > renders without yearControls correctly 1`] = `
-
+
@@ -6857,145 +6857,145 @@ exports[`Calendar > renders without yearControls correctly 1`] = ` diff --git a/test/components/__snapshots__/Calendar.spec.ts.snap b/test/components/__snapshots__/Calendar.spec.ts.snap index bf67f85c36..a162fd2afd 100644 --- a/test/components/__snapshots__/Calendar.spec.ts.snap +++ b/test/components/__snapshots__/Calendar.spec.ts.snap @@ -20,7 +20,7 @@ exports[`Calendar > renders with as correctly 1`] = `
-
+
@@ -36,145 +36,145 @@ exports[`Calendar > renders with as correctly 1`] = ` @@ -206,7 +206,7 @@ exports[`Calendar > renders with class correctly 1`] = `
-
+
@@ -222,145 +222,145 @@ exports[`Calendar > renders with class correctly 1`] = ` @@ -392,7 +392,7 @@ exports[`Calendar > renders with color neutral correctly 1`] = `
-
+
@@ -408,145 +408,145 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` @@ -578,7 +578,7 @@ exports[`Calendar > renders with day slot correctly 1`] = `
-
+
@@ -594,145 +594,145 @@ exports[`Calendar > renders with day slot correctly 1`] = ` @@ -764,7 +764,7 @@ exports[`Calendar > renders with default value correctly 1`] = `
-
+
@@ -780,145 +780,145 @@ exports[`Calendar > renders with default value correctly 1`] = ` @@ -950,7 +950,7 @@ exports[`Calendar > renders with disabled correctly 1`] = `
-
+
@@ -966,145 +966,145 @@ exports[`Calendar > renders with disabled correctly 1`] = ` @@ -1134,7 +1134,7 @@ exports[`Calendar > renders with heading slot correctly 1`] = `
-
+
@@ -1150,145 +1150,145 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` @@ -1320,7 +1320,7 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = `
-
+
@@ -1336,145 +1336,145 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` @@ -1506,7 +1506,7 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = `
-
+
@@ -1522,145 +1522,145 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` @@ -1692,7 +1692,7 @@ exports[`Calendar > renders with modelValue correctly 1`] = `
-
+
@@ -1708,145 +1708,145 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` @@ -1878,7 +1878,7 @@ exports[`Calendar > renders with multiple correctly 1`] = `
-
+
@@ -1894,145 +1894,145 @@ exports[`Calendar > renders with multiple correctly 1`] = ` @@ -2064,7 +2064,7 @@ exports[`Calendar > renders with nextMonth correctly 1`] = `
-
+
@@ -2080,145 +2080,145 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` @@ -2250,7 +2250,7 @@ exports[`Calendar > renders with nextYear correctly 1`] = `
-
+
@@ -2266,145 +2266,145 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` @@ -2436,7 +2436,7 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
-
+
@@ -2452,150 +2452,150 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
- +
@@ -2611,145 +2611,145 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` @@ -2781,7 +2781,7 @@ exports[`Calendar > renders with prevMonth correctly 1`] = `
-
+
@@ -2797,145 +2797,145 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` @@ -2967,7 +2967,7 @@ exports[`Calendar > renders with prevYear correctly 1`] = `
-
+
@@ -2983,145 +2983,145 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` @@ -3156,7 +3156,7 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = `
-
+
@@ -3172,145 +3172,145 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` @@ -3342,7 +3342,7 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = `
-
+
@@ -3358,145 +3358,145 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` @@ -3528,7 +3528,7 @@ exports[`Calendar > renders with range correctly 1`] = `
-
+
@@ -3544,145 +3544,145 @@ exports[`Calendar > renders with range correctly 1`] = ` @@ -3711,7 +3711,7 @@ exports[`Calendar > renders with readonly correctly 1`] = `
-
+
@@ -3727,145 +3727,145 @@ exports[`Calendar > renders with readonly correctly 1`] = ` @@ -3897,7 +3897,7 @@ exports[`Calendar > renders with size lg correctly 1`] = `
-
+
@@ -3913,145 +3913,145 @@ exports[`Calendar > renders with size lg correctly 1`] = ` @@ -4083,7 +4083,7 @@ exports[`Calendar > renders with size md correctly 1`] = `
-
+
@@ -4099,145 +4099,145 @@ exports[`Calendar > renders with size md correctly 1`] = ` @@ -4269,7 +4269,7 @@ exports[`Calendar > renders with size sm correctly 1`] = `
-
+
@@ -4285,145 +4285,145 @@ exports[`Calendar > renders with size sm correctly 1`] = ` @@ -4455,7 +4455,7 @@ exports[`Calendar > renders with size xl correctly 1`] = `
-
+
@@ -4471,145 +4471,145 @@ exports[`Calendar > renders with size xl correctly 1`] = ` @@ -4641,7 +4641,7 @@ exports[`Calendar > renders with size xs correctly 1`] = `
-
+
@@ -4657,145 +4657,145 @@ exports[`Calendar > renders with size xs correctly 1`] = ` @@ -4827,7 +4827,7 @@ exports[`Calendar > renders with ui correctly 1`] = `
-
+
@@ -4843,145 +4843,145 @@ exports[`Calendar > renders with ui correctly 1`] = ` @@ -5013,7 +5013,7 @@ exports[`Calendar > renders with variant outline correctly 1`] = `
-
+
@@ -5029,145 +5029,145 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` @@ -5199,7 +5199,7 @@ exports[`Calendar > renders with variant soft correctly 1`] = `
-
+
@@ -5215,145 +5215,145 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` @@ -5385,7 +5385,7 @@ exports[`Calendar > renders with variant solid correctly 1`] = `
-
+
@@ -5401,145 +5401,145 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` @@ -5571,7 +5571,7 @@ exports[`Calendar > renders with variant subtle correctly 1`] = `
-
+
@@ -5587,145 +5587,145 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` @@ -5757,7 +5757,7 @@ exports[`Calendar > renders with week-day slot correctly 1`] = `
-
+
@@ -5773,145 +5773,145 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` @@ -5943,7 +5943,7 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = `
-
+
@@ -5959,145 +5959,145 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` @@ -6129,7 +6129,7 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = `
-
+
@@ -6145,145 +6145,145 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` @@ -6315,7 +6315,7 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = `
-
+
@@ -6331,145 +6331,145 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` @@ -6501,7 +6501,7 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = `
-
+
@@ -6517,121 +6517,121 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` @@ -6659,7 +6659,7 @@ exports[`Calendar > renders without monthControls correctly 1`] = `
-
+
@@ -6675,145 +6675,145 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` @@ -6841,7 +6841,7 @@ exports[`Calendar > renders without yearControls correctly 1`] = `
-
+
@@ -6857,145 +6857,145 @@ exports[`Calendar > renders without yearControls correctly 1`] = ` diff --git a/test/nuxt/setup.ts b/test/nuxt/setup.ts index c3ecf6a93e..3c65e062c6 100644 --- a/test/nuxt/setup.ts +++ b/test/nuxt/setup.ts @@ -19,4 +19,49 @@ configureAxe({ } }) +function createStorageMock() { + const store = new Map() + + return { + get length() { + return store.size + }, + clear() { + store.clear() + }, + getItem(key: string) { + return store.get(key) ?? null + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null + }, + removeItem(key: string) { + store.delete(key) + }, + setItem(key: string, value: string) { + store.set(key, String(value)) + } + } +} + +const storage = createStorageMock() + +if (typeof window !== 'undefined') { + const localStorage = window.localStorage + + if (!localStorage || typeof localStorage.getItem !== 'function' || typeof localStorage.setItem !== 'function') { + Object.defineProperty(window, 'localStorage', { + value: storage, + configurable: true + }) + } +} + +if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function' || typeof globalThis.localStorage.setItem !== 'function') { + Object.defineProperty(globalThis, 'localStorage', { + value: storage, + configurable: true + }) +} + expect.extend(matchers) diff --git a/test/utils/setup.ts b/test/utils/setup.ts index 93adebdb53..d6d59354bc 100644 --- a/test/utils/setup.ts +++ b/test/utils/setup.ts @@ -12,6 +12,47 @@ window.IntersectionObserver = class IntersectionObserver { disconnect() {} } +function createStorageMock() { + const store = new Map() + + return { + get length() { + return store.size + }, + clear() { + store.clear() + }, + getItem(key: string) { + return store.get(key) ?? null + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null + }, + removeItem(key: string) { + store.delete(key) + }, + setItem(key: string, value: string) { + store.set(key, String(value)) + } + } +} + +const storage = createStorageMock() + +if (!window.localStorage || typeof window.localStorage.getItem !== 'function' || typeof window.localStorage.setItem !== 'function') { + Object.defineProperty(window, 'localStorage', { + value: storage, + configurable: true + }) +} + +if (typeof globalThis.localStorage === 'undefined' || typeof globalThis.localStorage.getItem !== 'function' || typeof globalThis.localStorage.setItem !== 'function') { + Object.defineProperty(globalThis, 'localStorage', { + value: storage, + configurable: true + }) +} + configureAxe({ globalOptions: { rules: [{