diff --git a/packages/api-generator/src/locale/en/VDatePicker.json b/packages/api-generator/src/locale/en/VDatePicker.json index 986406d5a11..796a109ac07 100644 --- a/packages/api-generator/src/locale/en/VDatePicker.json +++ b/packages/api-generator/src/locale/en/VDatePicker.json @@ -20,6 +20,7 @@ "nextIcon": "Sets the icon for next month/year button.", "prevIcon": "Sets the icon for previous month/year button.", "readonly": "Makes the picker readonly (doesn't allow to select new date).", + "scrollable": "Enables mouse wheel and touch swipe interactions to navigate between months.", "showAdjacentMonths": "Toggles visibility of days from previous and next months.", "showWeek": "Toggles visibility of the week numbers in the body of the calendar.", "width": "Width of the picker.", diff --git a/packages/docs/src/examples/v-date-picker/prop-scrollable.vue b/packages/docs/src/examples/v-date-picker/prop-scrollable.vue new file mode 100644 index 00000000000..acb1c29212e --- /dev/null +++ b/packages/docs/src/examples/v-date-picker/prop-scrollable.vue @@ -0,0 +1,8 @@ + diff --git a/packages/docs/src/pages/en/components/date-pickers.md b/packages/docs/src/pages/en/components/date-pickers.md index efb3640e6ec..7d61e51a690 100644 --- a/packages/docs/src/pages/en/components/date-pickers.md +++ b/packages/docs/src/pages/en/components/date-pickers.md @@ -75,6 +75,12 @@ By default days from previous and next months are not visible. They can be displ +#### Scrollable + +Use **scrollable** to navigate months with mouse wheel and touch swipe gestures. + + + #### Colors Date picker colors can be set using the **color** props. diff --git a/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.sass b/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.sass index 740042942e3..42b18e78c4c 100644 --- a/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.sass +++ b/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.sass @@ -22,6 +22,9 @@ .v-date-picker-month__days width: 100% + &--scrollable + touch-action: none + .v-date-picker-month__days-row display: grid grid-template-columns: repeat(var(--v-date-picker-days-in-week), 1fr) diff --git a/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx b/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx index e7c3152fca3..246f608a79c 100644 --- a/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx +++ b/packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx @@ -15,13 +15,17 @@ import { useProxiedModel } from '@/composables/proxiedModel' import { useRangePicker } from '@/composables/rangePicker' import { MaybeTransition } from '@/composables/transition' +// Directives +import vTouch from '@/directives/touch' + // Utilities import { computed, nextTick, shallowRef, toRef, useId, watch } from 'vue' -import { chunkArray, genericComponent, omit, propsFactory, useRender, wrapInArray } from '@/util' +import { chunkArray, genericComponent, omit, propsFactory, throttle, useRender, wrapInArray } from '@/util' // Types import type { PropType } from 'vue' import type { CalendarWeekdays } from '@/composables/calendar' +import type { TouchValue } from '@/directives/touch' import type { GenericProps } from '@/util' export type DatePickerEventColorValue = boolean | string | string[] @@ -48,6 +52,7 @@ export const makeVDatePickerMonthProps = propsFactory({ multiple: [Boolean, Number, String] as PropType, showWeek: Boolean, readonly: Boolean, + scrollable: Boolean, transition: { type: String, default: 'picker-transition', @@ -78,6 +83,8 @@ export const VDatePickerMonth = genericComponent( ) => GenericProps>()({ name: 'VDatePickerMonth', + directives: { vTouch }, + props: makeVDatePickerMonthProps(), emits: { @@ -275,6 +282,56 @@ export const VDatePickerMonth = genericComponent( } } + function navigateMonth (direction: 1 | -1) { + const firstCurrentDay = daysInMonth.value.find(day => !day.isAdjacent)?.date + if (!firstCurrentDay) return + + const minDate = props.min != null ? adapter.date(props.min) : null + const maxDate = props.max != null ? adapter.date(props.max) : null + + if (direction > 0) { + const nextMonthStart = adapter.addDays(adapter.endOfMonth(firstCurrentDay), 1) + + if (maxDate && adapter.isAfter(nextMonthStart, maxDate)) return + + emit('update:month', adapter.getMonth(nextMonthStart)) + emit('update:year', adapter.getYear(nextMonthStart)) + } else { + const prevMonthEnd = adapter.addDays(adapter.startOfMonth(firstCurrentDay), -1) + + if (minDate && adapter.isAfter(minDate, prevMonthEnd)) return + + emit('update:month', adapter.getMonth(prevMonthEnd)) + emit('update:year', adapter.getYear(prevMonthEnd)) + } + } + + const onWheelThrottled = throttle((deltaY: number) => { + navigateMonth(deltaY > 0 ? 1 : -1) + }, 250, { leading: true, trailing: false }) + + function onWheel (event: WheelEvent) { + if (!props.scrollable || event.deltaY === 0) return + + event.preventDefault() + onWheelThrottled(event.deltaY) + } + + const touchOptions = computed(() => { + if (!props.scrollable) return false + + return { + options: { passive: false }, + move: ({ originalEvent }) => { + originalEvent.preventDefault() + }, + left: () => navigateMonth(1), + right: () => navigateMonth(-1), + up: () => navigateMonth(1), + down: () => navigateMonth(-1), + } + }) + function focusGrid () { containerEl.value?.focus() } @@ -358,9 +415,14 @@ export const VDatePickerMonth = genericComponent(
{ !props.hideWeekdays && ( diff --git a/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.browser.tsx b/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.browser.tsx index 4948d4b54a4..70b1a4093a5 100644 --- a/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.browser.tsx +++ b/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.browser.tsx @@ -8,6 +8,34 @@ import { commands } from 'vitest/browser' import { nextTick, ref } from 'vue' describe('VDatePicker', () => { + function dispatchTouchEvent (target: Element, type: 'touchstart' | 'touchmove' | 'touchend', x: number, y: number) { + const touchPoint = new Touch({ + identifier: 1, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + screenX: x, + screenY: y, + radiusX: 1, + radiusY: 1, + rotationAngle: 0, + force: 1, + }) + const isEnd = type === 'touchend' + const touches = isEnd ? [] : [touchPoint] + const changedTouches = [touchPoint] + + target.dispatchEvent(new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches, + targetTouches: touches, + changedTouches, + })) + } + it('selects a range of dates', async () => { const model = ref([]) render(() => ( @@ -118,4 +146,43 @@ describe('VDatePicker', () => { expect(document.querySelector('[data-v-date="2026-01-15"]')).not.toBeNull() expect(document.querySelector('[data-v-date="2026-02-20"]')).toBeNull() }) + + it('scrollable should navigate to next month on mouse wheel', async () => { + render(() => ( + + )) + + const days = document.querySelector('.v-date-picker-month__days') + expect(days).not.toBeNull() + + days!.dispatchEvent(new WheelEvent('wheel', { deltaY: 1, bubbles: true, cancelable: true })) + await nextTick() + + expect(document.querySelector('[data-v-date="2026-02-15"]')).not.toBeNull() + }) + + it('scrollable should navigate to next month on swipe gesture', async () => { + render(() => ( + + )) + + const days = document.querySelector('.v-date-picker-month__days') + expect(days).not.toBeNull() + + dispatchTouchEvent(days!, 'touchstart', 120, 120) + dispatchTouchEvent(days!, 'touchend', 40, 120) + await nextTick() + + expect(document.querySelector('[data-v-date="2026-02-15"]')).not.toBeNull() + }) })