Repository: vuetifyjs/vuetify
Pull request #21466: feat(VDatePicker): disable months and years if not allowed
Author: login: ikushum
Labels: name: C: VDatePicker
Created: 2025-05-22T09:20:16Z | Merged: 2025-05-28T14:05:59Z

fixes #20465

## Description
Disable month and year buttons based on `allowed-dates` prop when:
- [x]  `allowed-dates` is array
- [x]  `allowed-dates` is function (open for suggestion/help)

## Markup:
<!--
Information on how to set up your local development environment can be found here:
https://vuetifyjs.com/getting-started/contributing/#setting-up-your-environment
Remove this section for documentation or test-only changes.
-->

<!-- Paste your FULL packages/vuetify/dev/Playground.vue here --->
```vue
<template>
  <v-app>
    <v-container>
        <h2>Date picker</h2>
        <v-date-picker
          mode="month"
          hide-header
          :allowed-dates="['2025-05-01', '2025-03-02', '2024-02-01']"
        ></v-date-picker>

        <h2>Date picker years</h2>
        <VDatePickerYears
          :allowed-years="[2025, 2026]"
          class="mb-10"
        />

        <h2>Date picker months</h2>
        <VDatePickerMonths
          :allowed-months="[2, 3]"
        />
    </v-container>
  </v-app>
</template>

```

## Screenshots
![image](https://github.com/user-attachments/assets/a2ffc6b8-6200-4840-8fe8-6cd115d599e2)
![image](https://github.com/user-attachments/assets/847b66b4-b335-4ae1-bf9c-fce6e2718156)


Linked issue #20465: [Feature Request] Date picker shows years which are not valid
### Problem to solve
if I use allowed-dates, in the year selection all years are visible/selectable, even if the selection of dates is not valid


https://play.vuetifyjs.com/#eNpVjsEKwjAMhl8l9KTg3H10BcG3sB5KG2FYs9K13UF8d2u04C4h+fIn/395iiXa/hTCsWQUg5AJH8GbhEoTgCydCYFbHuxMyUyE8YcYuqruwmTvGBsFGIz384qOt8uoRTE+I4wKuFFAuMK57nZ7LdqZkv3mXTOudOv8Id9gsv8LnMnhrYqcJvE6UPaey/UN+qVKwg==

### Proposed solution
Only years should be shown/active if at least one date is selectable

<!-- generated by vuetify-issue-helper. DO NOT REMOVE -->

Diff:
diff --git a/packages/api-generator/src/locale/en/VDatePickerMonths.json b/packages/api-generator/src/locale/en/VDatePickerMonths.json
index ebda95e16e4..b0be3c38f8d 100644
--- a/packages/api-generator/src/locale/en/VDatePickerMonths.json
+++ b/packages/api-generator/src/locale/en/VDatePickerMonths.json
@@ -2,7 +2,8 @@
   "props": {
     "min": "Sets the minimum selectable date. Months before this date will be disabled.",
     "max": "Sets the maximum selectable date. Months after this date will be disabled.",
-    "year": "Sets the year for the given months."
+    "year": "Sets the year for the given months.",
+    "allowedMonths": "Restricts which months can be selected."
   },
   "slots": {
     "month": "Slot for the month."
diff --git a/packages/api-generator/src/locale/en/VDatePickerYears.json b/packages/api-generator/src/locale/en/VDatePickerYears.json
index 51163d200e4..8f6e41e584a 100644
--- a/packages/api-generator/src/locale/en/VDatePickerYears.json
+++ b/packages/api-generator/src/locale/en/VDatePickerYears.json
@@ -2,7 +2,8 @@
   "props": {
     "max": "Sets the maximum date of the month.",
     "min": "Sets the minimum date of the month.",
-    "year": "Sets the year."
+    "year": "Sets the year.",
+    "allowedYears": "Restricts which years can be selected."
   },
   "slots": {
     "year": "Slot for the year."
diff --git a/packages/vuetify/src/components/VDatePicker/VDatePicker.tsx b/packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
index d8c94520820..4a66ec395c2 100644
--- a/packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
+++ b/packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
@@ -194,6 +194,51 @@ export const VDatePicker = genericComponent<new <
       return targets
     })
 
+    function isAllowedInRange (start: unknown, end: unknown) {
+      const allowedDates = props.allowedDates
+      if (typeof allowedDates !== 'function') return true
+      const days = adapter.getDiff(end, start, 'days')
+      for (let i = 0; i < days; i++) {
+        if (allowedDates(adapter.addDays(start, i))) return true
+      }
+      return false
+    }
+
+    function allowedYears (year: number) {
+      if (typeof props.allowedDates === 'function') {
+        const startOfYear = adapter.parseISO(`${year}-01-01`)
+        return isAllowedInRange(startOfYear, adapter.endOfYear(startOfYear))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        for (const date of props.allowedDates) {
+          if (adapter.getYear(adapter.date(date)) === year) return true
+        }
+        return false
+      }
+
+      return true
+    }
+
+    function allowedMonths (month: number) {
+      if (typeof props.allowedDates === 'function') {
+        const startOfMonth = adapter.parseISO(`${year.value}-${month + 1}-01`)
+        return isAllowedInRange(startOfMonth, adapter.endOfMonth(startOfMonth))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        for (const date of props.allowedDates) {
+          if (
+            adapter.getYear(adapter.date(date)) === year.value &&
+            adapter.getMonth(adapter.date(date)) === month
+          ) return true
+        }
+        return false
+      }
+
+      return true
+    }
+
     // function onClickAppend () {
     //   inputMode.value = inputMode.value === 'calendar' ? 'keyboard' : 'calendar'
     // }
@@ -340,19 +385,21 @@ export const VDatePicker = genericComponent<new <
                       key="date-picker-months"
                       { ...datePickerMonthsProps }
                       v-model={ month.value }
-                      onUpdate:modelValue={ onUpdateMonth }
                       min={ minDate.value }
                       max={ maxDate.value }
                       year={ year.value }
+                      allowedMonths={ allowedMonths }
+                      onUpdate:modelValue={ onUpdateMonth }
                     />
                   ) : viewMode.value === 'year' ? (
                     <VDatePickerYears
                       key="date-picker-years"
                       { ...datePickerYearsProps }
                       v-model={ year.value }
-                      onUpdate:modelValue={ onUpdateYear }
                       min={ minDate.value }
                       max={ maxDate.value }
+                      allowedYears={ allowedYears }
+                      onUpdate:modelValue={ onUpdateYear }
                     />
                   ) : (
                     <VDatePickerMonth
diff --git a/packages/vuetify/src/components/VDatePicker/VDatePickerMonths.tsx b/packages/vuetify/src/components/VDatePicker/VDatePickerMonths.tsx
index a412e75f446..f5f069b97b7 100644
--- a/packages/vuetify/src/components/VDatePicker/VDatePickerMonths.tsx
+++ b/packages/vuetify/src/components/VDatePicker/VDatePickerMonths.tsx
@@ -35,6 +35,7 @@ export const makeVDatePickerMonthsProps = propsFactory({
   max: null as any as PropType<unknown>,
   modelValue: Number,
   year: Number,
+  allowedMonths: [Array, Function] as PropType<number[] | ((date: number) => boolean)>,
 }, 'VDatePickerMonths')
 
 export const VDatePickerMonths = genericComponent<VDatePickerMonthsSlots>()({
@@ -59,6 +60,7 @@ export const VDatePickerMonths = genericComponent<VDatePickerMonthsSlots>()({
         const text = adapter.format(date, 'monthShort')
         const isDisabled =
           !!(
+            !isMonthAllowed(i) ||
             (props.min && adapter.isAfter(adapter.startOfMonth(adapter.date(props.min)), date)) ||
             (props.max && adapter.isAfter(date, adapter.startOfMonth(adapter.date(props.max))))
           )
@@ -76,6 +78,18 @@ export const VDatePickerMonths = genericComponent<VDatePickerMonthsSlots>()({
       model.value = model.value ?? adapter.getMonth(adapter.date())
     })
 
+    function isMonthAllowed (month: number) {
+      if (Array.isArray(props.allowedMonths) && props.allowedMonths.length) {
+        return props.allowedMonths.includes(month)
+      }
+
+      if (typeof props.allowedMonths === 'function') {
+        return props.allowedMonths(month)
+      }
+
+      return true
+    }
+
     useRender(() => (
       <div
         class="v-date-picker-months"
diff --git a/packages/vuetify/src/components/VDatePicker/VDatePickerYears.tsx b/packages/vuetify/src/components/VDatePicker/VDatePickerYears.tsx
index 4910d073cd2..91a1ee145d1 100644
--- a/packages/vuetify/src/components/VDatePicker/VDatePickerYears.tsx
+++ b/packages/vuetify/src/components/VDatePicker/VDatePickerYears.tsx
@@ -40,6 +40,7 @@ export const makeVDatePickerYearsProps = propsFactory({
   min: null as any as PropType<unknown>,
   max: null as any as PropType<unknown>,
   modelValue: Number,
+  allowedYears: [Array, Function] as PropType<number[] | ((date: number) => boolean)>,
 }, 'VDatePickerYears')
 
 export const VDatePickerYears = genericComponent<VDatePickerYearsSlots>()({
@@ -79,6 +80,7 @@ export const VDatePickerYears = genericComponent<VDatePickerYearsSlots>()({
         return {
           text,
           value: i,
+          isDisabled: !isYearAllowed(i),
         }
       })
     })
@@ -94,6 +96,18 @@ export const VDatePickerYears = genericComponent<VDatePickerYearsSlots>()({
       yearRef.el?.scrollIntoView({ block: 'center' })
     })
 
+    function isYearAllowed (year: number) {
+      if (Array.isArray(props.allowedYears) && props.allowedYears.length) {
+        return props.allowedYears.includes(year)
+      }
+
+      if (typeof props.allowedYears === 'function') {
+        return props.allowedYears(year)
+      }
+
+      return true
+    }
+
     useRender(() => (
       <div
         class="v-date-picker-years"
@@ -109,6 +123,7 @@ export const VDatePickerYears = genericComponent<VDatePickerYearsSlots>()({
               color: model.value === year.value ? props.color : undefined,
               rounded: true,
               text: year.text,
+              disabled: year.isDisabled,
               variant: model.value === year.value ? 'flat' : 'text',
               onClick: () => {
                 if (model.value === year.value) {


Reviews:
user: login: johnleider
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T19:45:51Z
user: login: johnleider
body: Looks good
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T19:46:16Z
user: login: johnleider
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T20:24:12Z
user: login: ikushum
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T03:38:04Z
user: login: KaelWD
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T05:57:20Z
user: login: johnleider
body: Approved pending added unit test.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-26T18:29:00Z

Review comments:
diff_hunk: @@ -76,6 +78,18 @@ export const VDatePickerMonths = genericComponent<VDatePickerMonthsSlots>()({
       model.value = model.value ?? adapter.getMonth(adapter.date())
     })
 
+    function isMonthAllowed (month: number) {
+      if (Array.isArray(props.allowedMonths) && props.allowedMonths.length) {
+        return props.allowedMonths.includes(month)
+      }
+
+      if (typeof props.allowedMonths === 'function') {
+        return props.allowedMonths(month)
+      }
+
+      return true
+    }
+
path: packages/vuetify/src/components/VDatePicker/VDatePickerMonths.tsx
user: login: johnleider
body: This is a good example of how the other ones should look.
created_at: 2025-05-22T19:45:51Z
author_association: MEMBER
diff_hunk: @@ -194,6 +194,44 @@ export const VDatePicker = genericComponent<new <
       return targets
     })
 
+    function allowedYears (year: number) {
+      if (typeof props.allowedDates === 'function') {
+        const isAllowedDate = props.allowedDates
+        const start = adapter.parseISO(`${year}-01-01`)
+        const yearSize = adapter.getDiff(adapter.endOfYear(start), start, 'days')
+
+        return createRange(yearSize)
+          .some(count => isAllowedDate(adapter.addDays(start, count)))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        return props.allowedDates.map(date => adapter.getYear(adapter.date(date))).includes(year)
+      }
+
+      return true
+    }
+
+    function allowedMonths (month: number) {
+      if (typeof props.allowedDates === 'function') {
+        const isAllowedDate = props.allowedDates
+        const start = adapter.parseISO(`${year.value}-${month}-01`)
+        const monthSize = adapter.getDiff(adapter.endOfMonth(start), start, 'days')
+
+        return createRange(monthSize)
+          .some(count => isAllowedDate(adapter.addDays(start, count)))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        if (!allowedYears(year.value)) return false
+
+        return props.allowedDates
+          .filter(date => adapter.getYear(adapter.date(date)) === year.value)
+          .map(date => adapter.getMonth(adapter.date(date))).includes(month)
+      }
+
+      return true
+    }
path: packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
user: login: johnleider
body: This looks great. Is it worth making some of the above reusable to avoid duplication?
created_at: 2025-05-22T20:24:12Z
author_association: MEMBER
diff_hunk: @@ -194,6 +194,44 @@ export const VDatePicker = genericComponent<new <
       return targets
     })
 
+    function allowedYears (year: number) {
+      if (typeof props.allowedDates === 'function') {
+        const isAllowedDate = props.allowedDates
+        const start = adapter.parseISO(`${year}-01-01`)
+        const yearSize = adapter.getDiff(adapter.endOfYear(start), start, 'days')
+
+        return createRange(yearSize)
+          .some(count => isAllowedDate(adapter.addDays(start, count)))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        return props.allowedDates.map(date => adapter.getYear(adapter.date(date))).includes(year)
+      }
+
+      return true
+    }
+
+    function allowedMonths (month: number) {
+      if (typeof props.allowedDates === 'function') {
+        const isAllowedDate = props.allowedDates
+        const start = adapter.parseISO(`${year.value}-${month}-01`)
+        const monthSize = adapter.getDiff(adapter.endOfMonth(start), start, 'days')
+
+        return createRange(monthSize)
+          .some(count => isAllowedDate(adapter.addDays(start, count)))
+      }
+
+      if (Array.isArray(props.allowedDates) && props.allowedDates.length) {
+        if (!allowedYears(year.value)) return false
+
+        return props.allowedDates
+          .filter(date => adapter.getYear(adapter.date(date)) === year.value)
+          .map(date => adapter.getMonth(adapter.date(date))).includes(month)
+      }
+
+      return true
+    }
path: packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
user: login: ikushum
body: made validation logic for function `allowedDates` reusable
created_at: 2025-05-23T03:38:04Z
author_association: MEMBER
diff_hunk: @@ -340,19 +380,21 @@ export const VDatePicker = genericComponent<new <
                       key="date-picker-months"
                       { ...datePickerMonthsProps }
                       v-model={ month.value }
-                      onUpdate:modelValue={ onUpdateMonth }
                       min={ minDate.value }
                       max={ maxDate.value }
                       year={ year.value }
+                      allowed-months={ allowedMonths }
path: packages/vuetify/src/components/VDatePicker/VDatePicker.tsx
user: login: KaelWD
body: Always use camelCase in jsx
created_at: 2025-05-23T05:29:47Z
author_association: MEMBER

PR comments:
user: login: ikushum
created_at: 2025-05-22T09:30:33Z
body: As mentioned in the PR description, this works only when `allowed-dates` is an array. 

I'm open for suggestion on how we can implement this (without heavy computation) for cases when `allowed-dates` is a function.
author_association: MEMBER
user: login: J-Sek
created_at: 2025-05-22T11:05:13Z
body: By "heavy computation" you probably meant those years.

```ts
// check if month is accepted by allowedDates
const firstDay = adapter.parseISO(`${year.value}-${month}-01`)
const monthSize = adapter.diff(
  adapter.endOfDay(adapter.endOfMonth(firstDay),
  adapter.startOfDay(firstDay)
)
return createRange(monthSize)
  .find((i) => props.allowedDates(adapter.addDays(firstDay, i)))
// up to 31 executions per month in the grid -- OK'ish
```

```ts
// check if year is accepted by allowedDates
const firstDay = adapter.parseISO(`${year.value}-01-01`)
const yearSize = adapter.diff(
  adapter.endOfDay(adapter.endOfYear(firstDay),
  adapter.startOfDay(firstDay)
)
return createRange(yearSize)
  .find((i) => props.allowedDates(adapter.addDays(firstDay, i)))
// up to 366 executions per year in the grid --- hm...
```

Let's consider real life scenarios:
- a birth date picker - will have all years but filtering specific dates makes no sense
- meeting date picker - could be forced to use function to cut off the weekends. It makes no sense without `min` and `max`, but we would rely on devs to remember. However executions per year will be in range 1-3, so the overhead is minimal

I would vote for including it and wait for feedback. We might first and foremost learn about scenarios that suffer from performance hit - learning about those is a benefit for the community and might exceed the risk of upsetting devs. Once we get some signals, we'd have couple of options to choose from:
- detect 10+ years in the grid and batch + throttle execution (e.g. 1000 per second)
- cache array of enabled years (expecting `allowedDays` function to be pure and rather static)
- change `allowedDays` function signature to take `year`, `month`, `day` instead of `date` and expect and object back (e.g. `{ year: true, month: false }` that would let us drop from the loop earlier)
  - could be shipped in v4.x as breaking change
author_association: CONTRIBUTOR
user: login: ikushum
created_at: 2025-05-22T16:56:58Z
body: @J-Sek, For the computation heavy changes, I created a [new PR](https://github.com/vuetifyjs/vuetify/pull/21470) that targets this branch 
author_association: MEMBER
user: login: KaelWD
created_at: 2025-05-23T05:57:04Z
body: > up to 31 executions per month in the grid

For some reason this is calling allowedDates 2061 times instead of just 365

> up to 366 executions per year in the grid

&times;152 years is over 55000 calls, should we only do this if min and max are explicitly set?

author_association: MEMBER
user: login: ikushum
created_at: 2025-05-23T06:24:34Z
body: > > up to 31 executions per month in the grid
> 
> For some reason this is calling allowedDates 2061 times instead of just 365
> 
> > up to 366 executions per year in the grid
> 
> ×152 years is over 55000 calls, should we only do this if min and max are explicitly set?

@KaelWD 

I just tested for worst case senario and `allowedMonths` makes `353` calls as expected.

![image](https://github.com/user-attachments/assets/1ea58cab-e30e-44a9-b6d2-218e1fb02722).

Maybe you mixed it up with calls from `allowedYears` too?

author_association: MEMBER
user: login: KaelWD
created_at: 2025-05-23T06:27:30Z
body: My last commit fixed it. 

> Maybe you mixed it up with calls from allowedYears too?

Well yeah it doesn't matter how many times a specific function calls it directly, only how many times it is invoked overall.
author_association: MEMBER
user: login: ikushum
created_at: 2025-05-23T06:32:33Z
body: > ×152 years is over 55000 calls, should we only do this if min and max are explicitly set?

Sounds fair, should we mention this somewhere in the documentation?
author_association: MEMBER