Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api-generator/src/locale/en/VDatePicker.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/src/examples/v-date-picker/prop-scrollable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<v-row class="justify-center">
<v-date-picker
scrollable
show-adjacent-months
></v-date-picker>
</v-row>
</template>
6 changes: 6 additions & 0 deletions packages/docs/src/pages/en/components/date-pickers.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ By default days from previous and next months are not visible. They can be displ

<ExamplesExample file="v-date-picker/prop-show-adjacent-months" />

#### Scrollable

Use **scrollable** to navigate months with mouse wheel and touch swipe gestures.

<ExamplesExample file="v-date-picker/prop-scrollable" />

#### Colors

Date picker colors can be set using the **color** props.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 64 additions & 2 deletions packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -48,6 +52,7 @@ export const makeVDatePickerMonthProps = propsFactory({
multiple: [Boolean, Number, String] as PropType<boolean | 'range' | number | (string & {})>,
showWeek: Boolean,
readonly: Boolean,
scrollable: Boolean,
transition: {
type: String,
default: 'picker-transition',
Expand Down Expand Up @@ -78,6 +83,8 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
) => GenericProps<typeof props, typeof slots>>()({
name: 'VDatePickerMonth',

directives: { vTouch },

props: makeVDatePickerMonthProps(),

emits: {
Expand Down Expand Up @@ -275,6 +282,56 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
}
}

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<TouchValue | false>(() => {
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()
}
Expand Down Expand Up @@ -358,9 +415,14 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
<MaybeTransition name={ transition.value }>
<div
key={ daysInMonth.value[0].date?.toString() }
class="v-date-picker-month__days"
class={[
'v-date-picker-month__days',
{ 'v-date-picker-month__days--scrollable': props.scrollable },
]}
role="grid"
onMouseleave={ range.clearPreview }
onWheel={ onWheel }
v-touch={ touchOptions.value }
{ ...containerProps.value }
>
{ !props.hideWeekdays && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown[]>([])
render(() => (
Expand Down Expand Up @@ -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(() => (
<VDatePicker
modelValue="2026-01-15"
month={ 0 }
year={ 2026 }
scrollable
/>
))

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(() => (
<VDatePicker
modelValue="2026-01-15"
month={ 0 }
year={ 2026 }
scrollable
/>
))

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()
})
})
Loading