Skip to content
Draft
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
2 changes: 1 addition & 1 deletion packages/api-generator/src/locale/en/VDatePicker.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"headerDateFormat": "Allows you to customize the format of the date selection text that appears in the header of the calendar.",
"max": "Maximum allowed date/month (ISO 8601 format).",
"min": "Minimum allowed date/month (ISO 8601 format).",
"multiple": "Allow the selection of multiple dates. The **range** value selects all dates between two selections.",
"multiple": "Allow the selection of multiple dates. The **range** value selects all dates between two selections, **week** selects the week a clicked day belongs to.",
"noAutoNavigation": "Prevents the displayed month from automatically following the selected value. Useful when building multi-calendar layouts where each picker manages its own view.",
"nextIcon": "Sets the icon for next month/year button.",
"prevIcon": "Sets the icon for previous month/year button.",
Expand Down
35 changes: 35 additions & 0 deletions packages/docs/src/examples/v-date-picker/prop-week-selection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<template>
<v-container class="d-flex align-start ga-12 justify-center flex-wrap">
<v-date-picker
v-model="dates"
color="primary"
multiple="week"
show-adjacent-months
show-week
></v-date-picker>
<div>
<v-date-input
v-model="dates"
color="primary"
label="Week"
multiple="week"
width="300"
></v-date-input>
<pre>{{ dates }}</pre>
</div>
</v-container>
</template>

<script setup>
import { ref } from 'vue'

const dates = ref([])
</script>

<script>
export default {
data: () => ({
dates: [],
}),
}
</script>
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 @@ -87,6 +87,12 @@ Specify allowed dates using objects or functions. When using objects, accepts a

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

#### Week selection

Setting **multiple** to `week` selects the whole week a clicked day belongs to. The model holds the first and last day of that week.

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

#### Landscape

Using `landscape` moves header to the side. You can customize it further using custom width and date format.
Expand Down
9 changes: 6 additions & 3 deletions packages/vuetify/src/components/VDateInput/VDateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextFi
// Composables
import { useCalendarRange } from '@/composables/calendar'
import { useDate } from '@/composables/date'
import { createWeekRange } from '@/composables/date/date'
import { makeDateFormatProps, useDateFormat } from '@/composables/dateFormat'
import { makeDisplayProps, useDisplay } from '@/composables/display'
import { makeFocusProps } from '@/composables/focus'
Expand Down Expand Up @@ -83,7 +84,7 @@ export const makeVDateInputProps = propsFactory({

export const VDateInput = genericComponent<new <
T,
Multiple extends boolean | 'range' | number | (string & {}) = false,
Multiple extends boolean | 'range' | 'week' | number | (string & {}) = false,
TModel = Multiple extends true | number | string
? T[]
: T,
Expand Down Expand Up @@ -156,7 +157,7 @@ export const VDateInput = genericComponent<new <
return t('$vuetify.datePicker.itemsSelected', value.length)
}

if (props.multiple === 'range') {
if (props.multiple === 'range' || props.multiple === 'week') {
const start = value[0]
const end = value[value.length - 1]

Expand Down Expand Up @@ -260,7 +261,9 @@ export const VDateInput = genericComponent<new <
} else {
const parts = value.trim().split(/\D+-\D+|[^\d\-/.]+/)
if (parts.every(isValid)) {
if (props.multiple === 'range') {
if (props.multiple === 'week') {
model.value = createWeekRange(adapter, clampDate(parseDate(parts[0])), props.firstDayOfWeek)
} else if (props.multiple === 'range') {
const [start, stop] = parts
.map(parseDate)
.map(clampDate)
Expand Down
33 changes: 23 additions & 10 deletions packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@ export const makeVDatePickerProps = propsFactory({
// type: String,
// default: 'dd/mm/yyyy',
// },
header: {
type: String,
default: '$vuetify.datePicker.header',
},
header: String,
headerColor: String,
headerDateFormat: {
type: String,
Expand All @@ -85,14 +82,14 @@ export const makeVDatePickerProps = propsFactory({
}),
...omit(makeVDatePickerMonthsProps(), ['modelValue', 'columns']),
...omit(makeVDatePickerYearsProps(), ['modelValue', 'columns']),
...makeVPickerProps({ title: '$vuetify.datePicker.title' }),
...makeVPickerProps(),

modelValue: null,
}, 'VDatePicker')

export const VDatePicker = genericComponent<new <
T,
Multiple extends boolean | 'range' | number | (string & {}) = false,
Multiple extends boolean | 'range' | 'week' | number | (string & {}) = false,
TModel = Multiple extends true | number | string
? T[]
: T,
Expand Down Expand Up @@ -132,6 +129,8 @@ export const VDatePicker = genericComponent<new <
)

const viewMode = useProxiedModel(props, 'viewMode')
// owns the hover preview so VDatePickerMonth isn't handed a prop nobody writes back
const previewValue = useProxiedModel(props, 'previewValue')
// const inputMode = useProxiedModel(props, 'inputMode')

const { minDate, maxDate, clampDate } = useCalendarRange(props)
Expand Down Expand Up @@ -159,7 +158,19 @@ export const VDatePicker = genericComponent<new <
})

const isReversing = shallowRef(false)
const isWeek = toRef(() => props.multiple === 'week')
const titleText = toRef(() => t(
props.title ?? (isWeek.value ? '$vuetify.datePicker.week.title' : '$vuetify.datePicker.title')
))
const header = computed(() => {
if (isWeek.value && model.value.length) {
return t(
'$vuetify.datePicker.week.selected',
adapter.getWeek(model.value[0], props.firstDayOfWeek, props.firstDayOfYear),
adapter.getYear(model.value[0]),
)
}

if (props.multiple === 'range' && model.value.length === 2) {
const [startDate, endDate] = model.value
const daysBetween = adapter.getDiff(endDate, startDate, 'days') + 1
Expand All @@ -173,7 +184,7 @@ export const VDatePicker = genericComponent<new <

const formattedDate = (model.value[0] && adapter.isValid(model.value[0]))
? adapter.format(adapter.date(model.value[0]), props.headerDateFormat)
: t(props.header)
: t(props.header ?? (isWeek.value ? '$vuetify.datePicker.week.header' : '$vuetify.datePicker.header'))

return props.landscape && formattedDate.split(' ').length === 3
? formattedDate.replace(' ', '\n')
Expand Down Expand Up @@ -374,7 +385,9 @@ export const VDatePicker = genericComponent<new <
if (!arrAfter.length) return

const before = adapter.date(arrBefore[arrBefore.length - 1])
const after = adapter.date(arrAfter[arrAfter.length - 1])
// a week can straddle two months — keep the one already on screen
const onScreen = isWeek.value && arrAfter.find(d => adapter.getMonth(adapter.date(d)) === month.value)
const after = adapter.date(onScreen || arrAfter[arrAfter.length - 1])

if (adapter.isSameDay(before, after)) return

Expand Down Expand Up @@ -430,7 +443,7 @@ export const VDatePicker = genericComponent<new <
v-slots={{
title: () => slots.title?.() ?? (
<div class="v-date-picker__title">
{ t(props.title) }
{ titleText.value }
</div>
),
header: () => slots.header ? (
Expand Down Expand Up @@ -509,7 +522,7 @@ export const VDatePicker = genericComponent<new <
v-model:year={ year.value }
onUpdate:month={ onUpdateMonth }
onUpdate:year={ onUpdateYear }
onUpdate:previewValue={ (value: any) => emit('update:previewValue', value) }
v-model:previewValue={ previewValue.value }
onBoundaryNavigate={ (payload: any) => emit('boundary-navigate', payload) }
min={ minDate.value }
max={ maxDate.value }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
grid-template-columns: repeat(var(--v-date-picker-days-in-week), 1fr)
justify-items: center

.v-date-picker-month--week .v-date-picker-month__days-row:has(.v-btn:not(.v-btn--disabled))
cursor: pointer

.v-date-picker-month__day
align-items: center
display: flex
Expand Down
39 changes: 34 additions & 5 deletions packages/vuetify/src/components/VDatePicker/VDatePickerMonth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { VBtn } from '@/components/VBtn'
// Composables
import { makeCalendarProps, useCalendar } from '@/composables/calendar'
import { useBackgroundColor } from '@/composables/color'
import { useDate } from '@/composables/date/date'
import { createWeekRange, useDate } from '@/composables/date/date'
import { useGridSelection } from '@/composables/gridSelection'
import { useLocale } from '@/composables/locale'
import { useProxiedModel } from '@/composables/proxiedModel'
Expand Down Expand Up @@ -45,7 +45,7 @@ export type VDatePickerMonthSlots = {
export const makeVDatePickerMonthProps = propsFactory({
color: String,
hideWeekdays: Boolean,
multiple: [Boolean, Number, String] as PropType<boolean | 'range' | number | (string & {})>,
multiple: [Boolean, Number, String] as PropType<boolean | 'range' | 'week' | number | (string & {})>,
showWeek: Boolean,
readonly: Boolean,
transition: {
Expand Down Expand Up @@ -107,16 +107,20 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
}

const previewValue = useProxiedModel(props, 'previewValue')
const isWeek = toRef(() => props.multiple === 'week')

const range = useRangePicker({
multiple: computed(() => {
if (props.multiple === 'range') return 'range'
if (props.multiple === 'range' || isWeek.value) return 'range'
return !!props.multiple
}),
model,
compare: compareDays,
normalizeEnd: (value: unknown) => adapter.endOfDay(value),
previewValue,
expand: value => isWeek.value
? createWeekRange(adapter, value, props.firstDayOfWeek)
: null,
})

const selectionColor = toRef(() => props.color || 'surface-variant')
Expand Down Expand Up @@ -275,6 +279,23 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
}
}

// in week mode the whole row is the target, so the gaps between day buttons stay live
function weekRowTarget (row: typeof daysInMonth.value) {
return row.find(item => !item.isAdjacent && !isDayDisabled(item)) ?? row.find(item => !isDayDisabled(item))
}

function onWeekRowEnter (row: typeof daysInMonth.value) {
range.setPreview(weekRowTarget(row)?.date)
}

function onWeekRowClick (row: typeof daysInMonth.value, e: MouseEvent) {
if ((e.target as HTMLElement).closest('.v-date-picker-month__day-btn')) return

const item = weekRowTarget(row)

if (item) onDayClick(item)
}

function focusGrid () {
containerEl.value?.focus()
}
Expand Down Expand Up @@ -336,7 +357,10 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
}
useRender(() => (
<div
class="v-date-picker-month"
class={[
'v-date-picker-month',
{ 'v-date-picker-month--week': isWeek.value },
]}
style={{ '--v-date-picker-days-in-week': props.weekdays.length }}
>
{ props.showWeek && (
Expand Down Expand Up @@ -377,7 +401,12 @@ export const VDatePickerMonth = genericComponent<new <TModel>(
)}

{ dayRows.value.map((row, rowIndex) => (
<div class="v-date-picker-month__days-row" role="row">
<div
class="v-date-picker-month__days-row"
role="row"
onMouseenter={ isWeek.value ? () => onWeekRowEnter(row) : undefined }
onClick={ isWeek.value ? (e: MouseEvent) => onWeekRowClick(row, e) : undefined }
>
{ row.map((item, colIndex) => {
const i = rowIndex * props.weekdays.length + colIndex
const isSelected = isSelectedDay(item)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { VDatePicker } from '../VDatePicker'

// import { touch } from '@/../test'
import { render, screen } from '@test'
import { h, ref } from 'vue'
import { h, nextTick, ref } from 'vue'
import {
mount,
MountOptions,
Expand Down Expand Up @@ -838,3 +838,44 @@ describe('range selection with time zone', () => {
expect(update).toHaveBeenNthCalledWith(2, [new Date('2025-10-02T04:00:00.000Z'), new Date('2025-10-04T03:59:59.999Z')])
})
})

describe('week selection', () => {
function renderWeekPicker (initialValue: unknown[]) {
const update = vi.fn()
const modelValue = ref<readonly unknown[]>(initialValue)
update.mockImplementation((v: unknown[]) => { modelValue.value = v })
const wrapper = render(() => h(VDatePicker, {
multiple: 'week',
modelValue: modelValue.value,
'onUpdate:modelValue': update,
}))
return { wrapper, update }
}

it('should select the whole week with a single click', async () => {
const { wrapper, update } = renderWeekPicker([new Date(2025, 3, 1)])

const btn = await wrapper.findByText('9') as HTMLElement
btn.click()

expect(update).toHaveBeenCalledWith([new Date(2025, 3, 6), new Date(2025, 3, 12, 23, 59, 59, 999)])
})

it('should show the week number in the header', async () => {
renderWeekPicker([new Date(2025, 3, 9)])

const $header = await screen.findByCSS('.v-date-picker-header__content')
expect($header.textContent).toBe('Week 15, 2025')
})

it('should stay on the displayed month when the week straddles two months', async () => {
const { wrapper } = renderWeekPicker([new Date(2025, 3, 15)])

const btn = await wrapper.findByText('30') as HTMLElement
btn.click()
await nextTick()

const $controls = await screen.findByCSS('.v-date-picker-controls')
expect($controls.textContent).toContain('Apr2025')
})
})
Loading
Loading