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/content/docs/2.components/calendar.md b/docs/content/docs/2.components/calendar.md index cb26a7b2fd..6f4dad2a00 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,16 @@ 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 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..3caee979fd 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/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 1339bb2527..7592bbcd98 100644 --- a/src/theme/calendar.ts +++ b/src/theme/calendar.ts @@ -1,19 +1,69 @@ import type { ModuleOptions } from '../module' +type PickerTriggerVariant = 'solid' | 'outline' | 'soft' | 'subtle' +type CalendarPanelType = 'day' | 'month' | 'year' + +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${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}${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}${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${today} data-[highlighted]:bg-${color}/20 hover:not-data-[selected]:bg-${color}/20` + } +} + +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${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${today} data-[highlighted]:bg-inverted/10 hover:not-data-[selected]:bg-inverted/10` + case 'soft': + 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${today} 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', - grid: 'w-full border-collapse select-none space-y-1 focus:outline-none', - gridRow: 'grid grid-cols-7 place-items-center', + heading: 'mx-auto text-center font-medium', + 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'], + 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: { @@ -33,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', @@ -40,7 +105,6 @@ export default (options: Required) => ({ cellWeek: 'text-xs', headCell: 'text-[10px]', headCellWeek: 'text-[10px]', - cellTrigger: 'size-7', body: 'space-y-2 pt-2' }, sm: { @@ -48,89 +112,86 @@ export default (options: Required) => ({ headCell: 'text-xs', headCellWeek: 'text-xs', cellWeek: 'text-xs', - cell: 'text-xs', - cellTrigger: 'size-7' + cell: 'text-xs' }, md: { heading: 'text-sm', headCell: 'text-xs', headCellWeek: 'text-xs', cellWeek: 'text-xs', - cell: 'text-sm', - cellTrigger: 'size-8' + cell: 'text-sm' }, lg: { heading: 'text-md', headCell: 'text-md', - headCellWeek: 'text-md', - cellTrigger: 'size-9 text-md' + headCellWeek: 'text-md' }, xl: { heading: 'text-lg', headCell: 'text-lg', - headCellWeek: 'text-lg', - cellTrigger: 'size-10 text-lg' + headCellWeek: 'text-lg' } }, weekNumbers: { - true: { + true: '' + } + }, + compoundVariants: [ + ...Object.entries(triggerSizeClasses.day).map(([size, cellTrigger]) => ({ + size, + type: 'day' as CalendarPanelType, + class: { cellTrigger } + })), + ...Object.entries(triggerSizeClasses.picker).map(([size, cellTrigger]) => ({ + size, + type: ['month', 'year'] as CalendarPanelType[], + class: { cellTrigger } + })), + { + type: 'day', + weekNumbers: true, + class: { gridRow: 'grid-cols-8', gridWeekDaysRow: 'grid-cols-8 [&>*:first-child]:col-start-2' } - } - }, - 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' - } - }], + }, + ...(options.theme.colors || []).flatMap((color: string) => pickerTriggerVariants.map(variant => ({ + color, + variant, + type: 'day' as CalendarPanelType, + class: { + cellTrigger: getTriggerClass(color, variant, true) + } + }))), + ...(options.theme.colors || []).flatMap((color: string) => pickerTriggerVariants.map(variant => ({ + color, + variant, + type: ['month', 'year'] as CalendarPanelType[], + class: { + cellTrigger: getTriggerClass(color, variant, false) + } + }))), + ...pickerTriggerVariants.map(variant => ({ + color: 'neutral', + variant, + type: 'day' as CalendarPanelType, + class: { + cellTrigger: getNeutralTriggerClass(variant, true) + } + })), + ...pickerTriggerVariants.map(variant => ({ + color: 'neutral', + variant, + type: ['month', 'year'] as CalendarPanelType[], + class: { + cellTrigger: getNeutralTriggerClass(variant, false) + } + })) + ], defaultVariants: { size: 'md', color: 'primary', - variant: 'solid' + variant: 'solid', + type: 'day' } }) diff --git a/test/components/Calendar.spec.ts b/test/components/Calendar.spec.ts index 4862b06030..f7b820cdc1 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,282 @@ 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('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: { + 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) + }) + + 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', () => { + 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..e75d067c54 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
+
- +
@@ -34,145 +36,145 @@ exports[`Calendar > renders with as correctly 1`] = ` @@ -193,7 +195,9 @@ exports[`Calendar > renders with class correctly 1`] = ` -
January 2025
+
-
+
@@ -218,145 +222,145 @@ exports[`Calendar > renders with class correctly 1`] = ` @@ -377,7 +381,9 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` -
January 2025
+
-
+
@@ -402,145 +408,145 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` @@ -561,7 +567,9 @@ exports[`Calendar > renders with day slot correctly 1`] = ` -
January 2025
+
-
+
@@ -586,145 +594,145 @@ exports[`Calendar > renders with day slot correctly 1`] = ` @@ -745,7 +753,9 @@ exports[`Calendar > renders with default value correctly 1`] = ` -
January 2025
+
-
+
@@ -770,145 +780,145 @@ exports[`Calendar > renders with default value correctly 1`] = ` @@ -929,7 +939,9 @@ exports[`Calendar > renders with disabled correctly 1`] = ` -
January 2025
+
-
+
@@ -954,145 +966,145 @@ exports[`Calendar > renders with disabled correctly 1`] = ` @@ -1113,7 +1125,7 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` -
Heading
-
+
@@ -1138,145 +1150,145 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` @@ -1297,7 +1309,9 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` -
January 2025
+
-
+
@@ -1322,145 +1336,145 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` @@ -1481,7 +1495,9 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` -
January 2025
+
-
+
@@ -1506,145 +1522,145 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` @@ -1665,7 +1681,9 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` -
January 2025
+
-
+
@@ -1690,145 +1708,145 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` @@ -1849,7 +1867,9 @@ exports[`Calendar > renders with multiple correctly 1`] = ` -
January 2025
+
-
+
@@ -1874,145 +1894,145 @@ exports[`Calendar > renders with multiple correctly 1`] = ` @@ -2033,7 +2053,9 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` -
January 2025
+
-
+
@@ -2058,145 +2080,145 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` @@ -2217,7 +2239,9 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` -
January 2025
+
-
+
@@ -2242,145 +2266,145 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` @@ -2401,7 +2425,9 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` -
January - February 2025
+
-
+
@@ -2426,150 +2452,150 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
- +
@@ -2585,145 +2611,145 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` @@ -2744,7 +2770,9 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` -
January 2025
+
-
+
@@ -2769,145 +2797,145 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` @@ -2928,7 +2956,9 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` -
January 2025
+
-
+
@@ -2953,145 +2983,145 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` @@ -3115,7 +3145,9 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` -
January 2025
+
-
+
@@ -3140,145 +3172,145 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` @@ -3299,7 +3331,9 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` -
January 2025
+
-
+
@@ -3324,145 +3358,145 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` @@ -3483,7 +3517,9 @@ exports[`Calendar > renders with range correctly 1`] = ` -
January 2025
+
-
+
@@ -3508,145 +3544,145 @@ exports[`Calendar > renders with range correctly 1`] = ` @@ -3664,7 +3700,9 @@ exports[`Calendar > renders with readonly correctly 1`] = ` -
January 2025
+
-
+
@@ -3689,145 +3727,145 @@ exports[`Calendar > renders with readonly correctly 1`] = ` @@ -3848,7 +3886,9 @@ exports[`Calendar > renders with size lg correctly 1`] = ` -
January 2025
+
-
+
@@ -3873,145 +3913,145 @@ exports[`Calendar > renders with size lg correctly 1`] = ` @@ -4032,7 +4072,9 @@ exports[`Calendar > renders with size md correctly 1`] = ` -
January 2025
+
-
+
@@ -4057,145 +4099,145 @@ exports[`Calendar > renders with size md correctly 1`] = ` @@ -4216,7 +4258,9 @@ exports[`Calendar > renders with size sm correctly 1`] = ` -
January 2025
+
-
+
@@ -4241,145 +4285,145 @@ exports[`Calendar > renders with size sm correctly 1`] = ` @@ -4400,7 +4444,9 @@ exports[`Calendar > renders with size xl correctly 1`] = ` -
January 2025
+
-
+
@@ -4425,145 +4471,145 @@ exports[`Calendar > renders with size xl correctly 1`] = ` @@ -4584,7 +4630,9 @@ exports[`Calendar > renders with size xs correctly 1`] = ` -
January 2025
+
-
+
@@ -4609,145 +4657,145 @@ exports[`Calendar > renders with size xs correctly 1`] = ` @@ -4768,7 +4816,9 @@ exports[`Calendar > renders with ui correctly 1`] = ` -
January 2025
+
-
+
@@ -4793,145 +4843,145 @@ exports[`Calendar > renders with ui correctly 1`] = ` @@ -4952,7 +5002,9 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` -
January 2025
+
-
+
@@ -4977,145 +5029,145 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` @@ -5136,7 +5188,9 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` -
January 2025
+
-
+
@@ -5161,145 +5215,145 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` @@ -5320,7 +5374,9 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` -
January 2025
+
-
+
@@ -5345,145 +5401,145 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` @@ -5504,7 +5560,9 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` -
January 2025
+
-
+
@@ -5529,145 +5587,145 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` @@ -5688,7 +5746,9 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` -
January 2025
+
-
+
@@ -5713,145 +5773,145 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` @@ -5872,7 +5932,9 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` -
January 2025
+
-
+
@@ -5897,145 +5959,145 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` @@ -6056,7 +6118,9 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` -
January 2025
+
-
+
@@ -6081,145 +6145,145 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` @@ -6240,7 +6304,9 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` -
January 2025
+
-
+
@@ -6265,145 +6331,145 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` @@ -6424,7 +6490,9 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` -
January 2025
+
-
+
@@ -6449,121 +6517,121 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` @@ -6582,14 +6650,16 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` -
January 2025
+
+
+
-
+
@@ -6605,145 +6675,145 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` @@ -6762,14 +6832,16 @@ exports[`Calendar > renders without yearControls correctly 1`] = ` -
January 2025
+
-
+
@@ -6785,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 61ad59cc42..a162fd2afd 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
+
-
+
@@ -34,145 +36,145 @@ exports[`Calendar > renders with as correctly 1`] = ` @@ -193,7 +195,9 @@ exports[`Calendar > renders with class correctly 1`] = ` -
January 2025
+
-
+
@@ -218,145 +222,145 @@ exports[`Calendar > renders with class correctly 1`] = ` @@ -377,7 +381,9 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` -
January 2025
+
-
+
@@ -402,145 +408,145 @@ exports[`Calendar > renders with color neutral correctly 1`] = ` @@ -561,7 +567,9 @@ exports[`Calendar > renders with day slot correctly 1`] = ` -
January 2025
+
-
+
@@ -586,145 +594,145 @@ exports[`Calendar > renders with day slot correctly 1`] = ` @@ -745,7 +753,9 @@ exports[`Calendar > renders with default value correctly 1`] = ` -
January 2025
+
-
+
@@ -770,145 +780,145 @@ exports[`Calendar > renders with default value correctly 1`] = ` @@ -929,7 +939,9 @@ exports[`Calendar > renders with disabled correctly 1`] = ` -
January 2025
+
-
+
@@ -954,145 +966,145 @@ exports[`Calendar > renders with disabled correctly 1`] = ` @@ -1113,7 +1125,7 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` -
Heading
-
+
@@ -1138,145 +1150,145 @@ exports[`Calendar > renders with heading slot correctly 1`] = ` @@ -1297,7 +1309,9 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` -
January 2025
+
-
+
@@ -1322,145 +1336,145 @@ exports[`Calendar > renders with isDateDisabled correctly 1`] = ` @@ -1481,7 +1495,9 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` -
January 2025
+
-
+
@@ -1506,145 +1522,145 @@ exports[`Calendar > renders with isDateUnavailable correctly 1`] = ` @@ -1665,7 +1681,9 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` -
January 2025
+
-
+
@@ -1690,145 +1708,145 @@ exports[`Calendar > renders with modelValue correctly 1`] = ` @@ -1849,7 +1867,9 @@ exports[`Calendar > renders with multiple correctly 1`] = ` -
January 2025
+
-
+
@@ -1874,145 +1894,145 @@ exports[`Calendar > renders with multiple correctly 1`] = ` @@ -2033,7 +2053,9 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` -
January 2025
+
-
+
@@ -2058,145 +2080,145 @@ exports[`Calendar > renders with nextMonth correctly 1`] = ` @@ -2217,7 +2239,9 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` -
January 2025
+
-
+
@@ -2242,145 +2266,145 @@ exports[`Calendar > renders with nextYear correctly 1`] = ` @@ -2401,7 +2425,9 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` -
January - February 2025
+
-
+
@@ -2426,150 +2452,150 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = `
- +
@@ -2585,145 +2611,145 @@ exports[`Calendar > renders with numberOfMonths correctly 1`] = ` @@ -2744,7 +2770,9 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` -
January 2025
+
-
+
@@ -2769,145 +2797,145 @@ exports[`Calendar > renders with prevMonth correctly 1`] = ` @@ -2928,7 +2956,9 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` -
January 2025
+
-
+
@@ -2953,145 +2983,145 @@ exports[`Calendar > renders with prevYear correctly 1`] = ` @@ -3115,7 +3145,9 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` -
January 2025
+
-
+
@@ -3140,145 +3172,145 @@ exports[`Calendar > renders with range and defaultValue correctly 1`] = ` @@ -3299,7 +3331,9 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` -
January 2025
+
-
+
@@ -3324,145 +3358,145 @@ exports[`Calendar > renders with range and modelValue correctly 1`] = ` @@ -3483,7 +3517,9 @@ exports[`Calendar > renders with range correctly 1`] = ` -
January 2025
+
-
+
@@ -3508,145 +3544,145 @@ exports[`Calendar > renders with range correctly 1`] = ` @@ -3664,7 +3700,9 @@ exports[`Calendar > renders with readonly correctly 1`] = ` -
January 2025
+
-
+
@@ -3689,145 +3727,145 @@ exports[`Calendar > renders with readonly correctly 1`] = ` @@ -3848,7 +3886,9 @@ exports[`Calendar > renders with size lg correctly 1`] = ` -
January 2025
+
-
+
@@ -3873,145 +3913,145 @@ exports[`Calendar > renders with size lg correctly 1`] = ` @@ -4032,7 +4072,9 @@ exports[`Calendar > renders with size md correctly 1`] = ` -
January 2025
+
-
+
@@ -4057,145 +4099,145 @@ exports[`Calendar > renders with size md correctly 1`] = ` @@ -4216,7 +4258,9 @@ exports[`Calendar > renders with size sm correctly 1`] = ` -
January 2025
+
-
+
@@ -4241,145 +4285,145 @@ exports[`Calendar > renders with size sm correctly 1`] = ` @@ -4400,7 +4444,9 @@ exports[`Calendar > renders with size xl correctly 1`] = ` -
January 2025
+
-
+
@@ -4425,145 +4471,145 @@ exports[`Calendar > renders with size xl correctly 1`] = ` @@ -4584,7 +4630,9 @@ exports[`Calendar > renders with size xs correctly 1`] = ` -
January 2025
+
-
+
@@ -4609,145 +4657,145 @@ exports[`Calendar > renders with size xs correctly 1`] = ` @@ -4768,7 +4816,9 @@ exports[`Calendar > renders with ui correctly 1`] = ` -
January 2025
+
-
+
@@ -4793,145 +4843,145 @@ exports[`Calendar > renders with ui correctly 1`] = ` @@ -4952,7 +5002,9 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` -
January 2025
+
-
+
@@ -4977,145 +5029,145 @@ exports[`Calendar > renders with variant outline correctly 1`] = ` @@ -5136,7 +5188,9 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` -
January 2025
+
-
+
@@ -5161,145 +5215,145 @@ exports[`Calendar > renders with variant soft correctly 1`] = ` @@ -5320,7 +5374,9 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` -
January 2025
+
-
+
@@ -5345,145 +5401,145 @@ exports[`Calendar > renders with variant solid correctly 1`] = ` @@ -5504,7 +5560,9 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` -
January 2025
+
-
+
@@ -5529,145 +5587,145 @@ exports[`Calendar > renders with variant subtle correctly 1`] = ` @@ -5688,7 +5746,9 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` -
January 2025
+
-
+
@@ -5713,145 +5773,145 @@ exports[`Calendar > renders with week-day slot correctly 1`] = ` @@ -5872,7 +5932,9 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` -
January 2025
+
-
+
@@ -5897,145 +5959,145 @@ exports[`Calendar > renders with weekNumbers correctly 1`] = ` @@ -6056,7 +6118,9 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` -
January 2025
+
-
+
@@ -6081,145 +6145,145 @@ exports[`Calendar > renders with weekStartsOn correctly 1`] = ` @@ -6240,7 +6304,9 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` -
January 2025
+
-
+
@@ -6265,145 +6331,145 @@ exports[`Calendar > renders with weekdayFormat correctly 1`] = ` @@ -6424,7 +6490,9 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` -
January 2025
+
-
+
@@ -6449,121 +6517,121 @@ exports[`Calendar > renders without fixedWeeks correctly 1`] = ` @@ -6582,14 +6650,16 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` -
January 2025
+
+
+
-
+
@@ -6605,145 +6675,145 @@ exports[`Calendar > renders without monthControls correctly 1`] = ` @@ -6762,14 +6832,16 @@ exports[`Calendar > renders without yearControls correctly 1`] = ` -
January 2025
+
-
+
@@ -6785,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: [{