Skip to content
Merged
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 @@ -22,6 +22,7 @@
"readonly": "Makes the picker readonly (doesn't allow to select new date).",
"showAdjacentMonths": "Toggles visibility of days from previous and next months.",
"showWeek": "Toggles visibility of the week numbers in the body of the calendar.",
"wheel": "Navigate between months with the mouse wheel or trackpad. Responds to horizontal scrolling or Shift + wheel.",
"width": "Width of the picker.",
"modeIcon": "Icon displayed next to the current month and year, toggles year selection when clicked.",
"viewMode": "Determines which picker in the date or month picker is being displayed. Allowed values: `'month'`, `'months'`, `'year'`."
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/src/data/new-in.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@
"landscape": "3.11.0",
"landscapeHeaderWidth": "3.11.0",
"noAutoNavigation": "4.1.0",
"noMonthPicker": "3.11.0"
"noMonthPicker": "3.11.0",
"wheel": "4.3.0"
},
"slots": {
"controls": "3.11.0"
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/src/examples/v-date-picker/prop-wheel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>
<v-row class="justify-center">
<v-date-picker
show-adjacent-months
wheel
></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" />

#### Wheel

Use **wheel** to navigate months with horizontal scrolling or Shift + mouse wheel.

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

#### 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,7 @@
font-size: $date-picker-month-font-size

.v-date-picker-month__days
touch-action: pan-y pinch-zoom
width: 100%

.v-date-picker-month__days-row
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 @@ -10,17 +10,21 @@ import { makeCalendarProps, useCalendar } from '@/composables/calendar'
import { useBackgroundColor } from '@/composables/color'
import { useDate } from '@/composables/date/date'
import { useGridSelection } from '@/composables/gridSelection'
import { useLocale } from '@/composables/locale'
import { useLocale, useRtl } from '@/composables/locale'
import { useProxiedModel } from '@/composables/proxiedModel'
import { useRangePicker } from '@/composables/rangePicker'
import { MaybeTransition } from '@/composables/transition'

// Directives
import vTouch from '@/directives/touch'

// Utilities
import { computed, mergeProps, nextTick, shallowRef, toRef, useId, watch } from 'vue'
import { computed, mergeProps, nextTick, onScopeDispose, shallowRef, toRef, useId, watch } from 'vue'
import {
chunkArray,
genericComponent,
getPrefixedEventHandlers,
IN_BROWSER,
isFunction,
isObject,
isString,
Expand All @@ -34,6 +38,7 @@ import {
// Types
import type { PropType } from 'vue'
import type { CalendarDay, CalendarWeekdays } from '@/composables/calendar'
import type { TouchValue } from '@/directives/touch'
import type { GenericProps } from '@/util'

export type DatePickerEventColorValue = boolean | string | string[]
Expand Down Expand Up @@ -62,6 +67,7 @@ export const makeVDatePickerMonthProps = propsFactory({
multiple: [Boolean, Number, String] as PropType<boolean | 'range' | number | (string & {})>,
showWeek: Boolean,
readonly: Boolean,
wheel: Boolean,
transition: {
type: String,
default: 'picker-transition',
Expand Down Expand Up @@ -92,6 +98,8 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
) => GenericProps<typeof props, typeof slots>>()({
name: 'VDatePickerMonth',

directives: { vTouch },

props: makeVDatePickerMonthProps(),

emits: {
Expand All @@ -105,6 +113,7 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
setup (props, { emit, slots, attrs }) {
const uid = useId()
const { t } = useLocale()
const { isRtl } = useRtl()

const { daysInMonth, model, weekNumbers, weekdayLabels } = useCalendar(props)
const adapter = useDate()
Expand Down Expand Up @@ -289,6 +298,57 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
}
}

function getAdjacentMonth (direction: number) {
const firstDay = daysInMonth.value.find(day => !day.isAdjacent)?.date
if (!firstDay || props.disabled) return undefined

if (direction > 0) {
const nextMonthStart = adapter.addDays(adapter.endOfMonth(firstDay), 1)
return props.max != null && adapter.isAfter(nextMonthStart, adapter.date(props.max)) ? undefined : nextMonthStart
}

const prevMonthEnd = adapter.addDays(adapter.startOfMonth(firstDay), -1)
return props.min != null && adapter.isAfter(adapter.date(props.min), prevMonthEnd) ? undefined : prevMonthEnd
}

function navigateMonth (direction: number) {
const target = getAdjacentMonth(isRtl.value ? -direction : direction)
if (!target) return

emit('update:month', adapter.getMonth(target))
emit('update:year', adapter.getYear(target))
}

let wheelTimeout = -1

function onWheel (e: WheelEvent) {
if (!props.wheel || e.defaultPrevented) return

const scrollDelta = e.deltaX || (e.shiftKey ? e.deltaY : 0)
if (!scrollDelta) return

const isLocked = wheelTimeout >= 0
if (!isLocked && !getAdjacentMonth(isRtl.value ? -scrollDelta : scrollDelta)) return

e.preventDefault()
// trackpad inertia keeps firing events, so one gesture moves one month
window.clearTimeout(wheelTimeout)
wheelTimeout = window.setTimeout(() => {
wheelTimeout = -1
}, 150)

if (!isLocked) navigateMonth(scrollDelta)
}

onScopeDispose(() => {
if (IN_BROWSER) window.clearTimeout(wheelTimeout)
})

const touch: TouchValue = {
left: () => navigateMonth(1),
right: () => navigateMonth(-1),
}

function focusGrid () {
containerEl.value?.focus()
}
Expand Down Expand Up @@ -378,6 +438,8 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
class="v-date-picker-month__days"
role="grid"
onMouseleave={ range.clearPreview }
onWheel={ onWheel }
v-touch={ touch }
{ ...containerProps.value }
>
{ !props.hideWeekdays && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,40 @@
import { VDatePicker } from '..'

// Utilities
import { render, screen, userEvent, waitIdle } from '@test'
import { render, screen, userEvent, wait, waitIdle } from '@test'
import { within } from '@testing-library/vue'
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 @@ -137,6 +165,49 @@ describe('VDatePicker', () => {
expect(document.querySelector('[data-v-date="2026-02-20"]')).toBeNull()
})

it('navigates months with horizontal wheel only when wheel is set', async () => {
render(() => (
<VDatePicker modelValue="2026-01-15" max="2026-02-20" wheel />
))

const days = () => document.querySelector('.v-date-picker-month__days')!
const wheel = (init: WheelEventInit) => {
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })
days().dispatchEvent(event)
return event
}

expect(wheel({ deltaY: 100 }).defaultPrevented).toBe(false)
await nextTick()
expect(document.querySelector('[data-v-date="2026-01-20"]')).not.toBeNull()

expect(wheel({ deltaY: 100, shiftKey: true }).defaultPrevented).toBe(true)
// inertia from the same gesture is swallowed
wheel({ deltaX: 100 })
await nextTick()
expect(document.querySelector('[data-v-date="2026-02-20"]')).not.toBeNull()

// polling would fire events and keep renewing the lock
await wait(200)
expect(wheel({ deltaX: 100 }).defaultPrevented).toBe(false)
expect(document.querySelector('[data-v-date="2026-02-20"]')).not.toBeNull()
})

it('navigates to next month on swipe gesture', async () => {
render(() => (
<VDatePicker modelValue="2026-01-15" />
))

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

it('previews the hovered end of a range', async () => {
render(() => (
<VDatePicker multiple="range" modelValue={[new Date(2025, 3, 8)]} />
Expand Down
Loading