Repository: gpbl/react-day-picker
Pull request #2194 (2024-06-06 00:24:44)

feat: dropdown options are disabled when navigating out of calendar range

github-actions[bot]: ## Pull request title linting :rotating_light:

In order to merge this pull request, the title of the pull request should be prefixed by one of the available types.

### Available types:

- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- **refactor**: A code change that neither fixes a bug nor adds a feature
- **perf**: A code change that improves performance
- **test**: Adding missing tests or correcting existing tests
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
- **chore**: Other changes that don't modify src or test files
- **revert**: Reverts a previous commit

---

<details>
<summary>Examples</summary>

```
feat(grpc): add new endpoint
refactor: combine class A and class B
ci: update pull request linter
style: change format of strings
```

</details>

:tipping_hand_person: For more examples, visit https://www.conventionalcommits.org/en/v1.0.0/#examples.

Patch:
diff --git a/examples/Dropdown.tsx b/examples/Dropdown.tsx
index d916cb519..23e55b6de 100644
--- a/examples/Dropdown.tsx
+++ b/examples/Dropdown.tsx
@@ -3,5 +3,11 @@ import React from "react";
 import { DayPicker } from "react-day-picker";
 
 export function Dropdown() {
-  return <DayPicker captionLayout="dropdown" fromYear={2010} />;
+  return (
+    <DayPicker
+      captionLayout="dropdown"
+      fromMonth={new Date(2015, 6)}
+      toMonth={new Date(2025, 9)}
+    />
+  );
 }
diff --git a/react-day-picker.code-workspace b/react-day-picker.code-workspace
index f485a7892..c5cc442ef 100644
--- a/react-day-picker.code-workspace
+++ b/react-day-picker.code-workspace
@@ -32,10 +32,8 @@
     },
     "search.exclude": {
       "**/node_modules": true,
-      "**/bower_components": true,
-      "**/*.code-search": true
-      // "**/versioned_docs": true,
-      // "**/examples/**/*": true
-    }
+      "**/versioned_docs": true
+    },
+    "jest.runMode": "on-save"
   }
 }
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index 21239675c..52aaa2d4d 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -1,5 +1,7 @@
 import React, { SelectHTMLAttributes } from "react";
 
+import type { Month } from "date-fns";
+
 import { UI } from "../UI";
 import { useProps } from "../contexts/props";
 
@@ -8,12 +10,17 @@ import { Option as DefaultOption } from "./Option";
 import { Select as DefaultSelect } from "./Select";
 
 /** An option to use in the dropdown. Maps to the `<option>` HTML element. */
-export type DropdownOption = [
+export type DropdownOption = {
   /** The value of the option. */
-  value: number,
+  value: Month | number;
   /** The label of the option. */
-  label: string
-];
+  label: string;
+  /**
+   * The dropdown option is disabled when it cannot be selected because out of
+   * the calendar range.
+   */
+  disabled: boolean;
+};
 
 /**
  * Render a dropdown component to use in the navigation bar.
@@ -40,19 +47,19 @@ export function Dropdown(
   const Chevron = components?.Chevron ?? DefaultChevron;
 
   const selectedOption = options?.find(
-    ([value]) => value === selectProps.value
+    ({ value }) => value === selectProps.value
   );
   return (
     <span className={cssClassRoot}>
       <Select className={cssClassSelect} {...selectProps}>
-        {options?.map(([value, label]) => (
-          <Option key={value} value={value}>
+        {options?.map(({ value, label, disabled }) => (
+          <Option key={value} value={value} disabled={disabled}>
             {label}
           </Option>
         ))}
       </Select>
       <span className={classNames[UI.CaptionLabel]} aria-hidden>
-        {selectedOption?.[1]}
+        {selectedOption?.label}
         <Chevron orientation="down" size={18} />
       </span>
     </span>
diff --git a/src/contexts/calendar.tsx b/src/contexts/calendar.tsx
index 1432aa3b8..9bc7392b1 100644
--- a/src/contexts/calendar.tsx
+++ b/src/contexts/calendar.tsx
@@ -138,9 +138,17 @@ export function CalendarProvider(providerProps: { children?: ReactNode }) {
     if (props.disableNavigation) {
       return;
     }
-    const month = startOfMonth(date);
-    setFirstMonth(month);
-    props.onMonthChange?.(month);
+    let newMonth = startOfMonth(date);
+    // if month is before fromDate, use the first month instead
+    if (props.fromDate && newMonth < startOfMonth(props.fromDate)) {
+      newMonth = startOfMonth(props.fromDate);
+    }
+    // if month is after toDate, use the last month instead
+    if (props.toDate && newMonth > startOfMonth(props.toDate)) {
+      newMonth = startOfMonth(props.toDate);
+    }
+    setFirstMonth(newMonth);
+    props.onMonthChange?.(newMonth);
   }
 
   function goToDay(day: CalendarDay) {
@@ -148,7 +156,7 @@ export function CalendarProvider(providerProps: { children?: ReactNode }) {
       return;
     }
 
-    // TODO:
+    // TODO:??
     // if (refDate && isBefore(date, refDate)) {
     //   console.log('date is before refDate');
     //   const month = addMonths(date, 1 + dayPicker.numberOfMonths * -1);
@@ -190,8 +198,8 @@ export function CalendarProvider(providerProps: { children?: ReactNode }) {
     isDayDisplayed,
 
     dropdownOptions: {
-      months: getDropdownMonths(props),
-      years: getDropdownYears(props)
+      months: getDropdownMonths(props, firstMonth.getFullYear()),
+      years: getDropdownYears(props, lastMonth.getMonth())
     }
   };
 
diff --git a/src/helpers/getDropdownMonths.ts b/src/helpers/getDropdownMonths.ts
index 974de6fd7..1303c8bd4 100644
--- a/src/helpers/getDropdownMonths.ts
+++ b/src/helpers/getDropdownMonths.ts
@@ -11,26 +11,38 @@ import { Formatters, Mode } from "../types";
 export function getDropdownMonths(
   props: Pick<PropsContext<Mode, boolean>, "fromDate" | "toDate" | "locale"> & {
     formatters: Pick<Formatters, "formatMonthDropdown">;
-  }
+  },
+  year?: number | undefined
 ): DropdownOption[] | undefined {
   if (!props.fromDate) return undefined;
   if (!props.toDate) return undefined;
   const navStartMonth = startOfMonth(props.fromDate);
   const navEndMonth = startOfMonth(props.toDate);
 
-  const months: Month[] = [];
+  const months: number[] = [];
   let month = navStartMonth;
   while (months.length < 12 && isBefore(month, addMonths(navEndMonth, 1))) {
-    months.push(month.getMonth() as Month);
+    months.push(month.getMonth());
     month = addMonths(month, 1);
   }
   const sortedMonths = months.sort((a, b) => {
     return a - b;
   });
-  const options = sortedMonths.map((m) => {
-    const label = props.formatters.formatMonthDropdown(m, props.locale);
-    const option: [number, string] = [m, label];
-    return option;
+  const options = sortedMonths.map((value) => {
+    const label = props.formatters.formatMonthDropdown(
+      value as Month,
+      props.locale
+    );
+    const disabled =
+      (year &&
+        props.fromDate &&
+        new Date(year, value) < startOfMonth(props.fromDate)) ||
+      (year &&
+        props.toDate &&
+        new Date(year, value) > startOfMonth(props.toDate)) ||
+      false;
+    return { value, label, disabled };
   });
+
   return options;
 }
diff --git a/src/helpers/getDropdownYears.ts b/src/helpers/getDropdownYears.ts
index ee97f9d1b..f3e08a197 100644
--- a/src/helpers/getDropdownYears.ts
+++ b/src/helpers/getDropdownYears.ts
@@ -2,6 +2,7 @@ import { addYears } from "date-fns/addYears";
 import { endOfYear } from "date-fns/endOfYear";
 import { isBefore } from "date-fns/isBefore";
 import { isSameYear } from "date-fns/isSameYear";
+import { startOfMonth } from "date-fns/startOfMonth";
 import { startOfYear } from "date-fns/startOfYear";
 
 import { DropdownOption } from "../components/Dropdown";
@@ -12,7 +13,8 @@ import type { Formatters, Mode } from "../types";
 export function getDropdownYears(
   props: Pick<PropsContext<Mode>, "fromDate" | "toDate"> & {
     formatters: Pick<Formatters, "formatYearDropdown">;
-  }
+  },
+  month?: number | undefined
 ): DropdownOption[] | undefined {
   if (!props.fromDate) return undefined;
   if (!props.toDate) return undefined;
@@ -24,5 +26,20 @@ export function getDropdownYears(
     years.push(year.getFullYear());
     year = addYears(year, 1);
   }
-  return years.map((year) => [year, props.formatters.formatYearDropdown(year)]);
+  return years.map((value) => {
+    const disabled =
+      (month &&
+        props.fromDate &&
+        new Date(value, month) < startOfMonth(props.fromDate)) ||
+      (month &&
+        props.toDate &&
+        new Date(value, month) > startOfMonth(props.toDate)) ||
+      false;
+    const label = props.formatters.formatYearDropdown(value);
+    return {
+      value,
+      label,
+      disabled
+    };
+  });
 }
diff --git a/website/docs/using-daypicker/customization.mdx b/website/docs/using-daypicker/customization.mdx
index 75d6de066..8a30f4ce8 100644
--- a/website/docs/using-daypicker/customization.mdx
+++ b/website/docs/using-daypicker/customization.mdx
@@ -16,7 +16,7 @@ Use the customization props to customize the appearance of the calendar.
 | `hideWeekdayRow`  | `boolean`                                                                             | Hide the row displaying the weekday names.                  |
 | `footer`          | `ReactNode`                                                                           | Displays a footer below the calendar.                       |
 
-### Caption Layout
+## Caption Layouts
 
 Use the `captionLayout` prop to choose the layout of the month caption:
 
@@ -33,20 +33,26 @@ Use the `captionLayout` prop to choose the layout of the month caption:
 
 ### Caption Dropdown
 
-Set `captionLayout="dropdown"` to display a navigation dropdown. This will allow navigation within the last 100 years. Use the `from*` and `to*` props to [set the start and end dates](./navigation.mdx#start-and-end-dates).
+To enable a navigation dropdown, assign `captionLayout="dropdown"`. Utilize `from*` and `to*` properties to define the [the start and end dates](./navigation.mdx#start-and-end-dates).
 
 ```tsx
-<DayPicker captionLayout="dropdown" />
-<DayPicker captionLayout="dropdown-months"  /> // Only months
-<DayPicker captionLayout="dropdown-years"  /> // Only years
-
-<DayPicker captionLayout="dropdown" fromYear={2015} toYear={2025} /> // Set the years range
+<DayPicker
+  captionLayout="dropdown"
+  fromMonth={new Date(2015, 6)}
+  toMonth={new Date(2025, 9)}
+/>
 ```
 
 <BrowserWindow>
   <Examples.Dropdown />
 </BrowserWindow>
 
+:::info Default Range
+
+Without specifing the `from*` and `to*` properties, the dropdown will show the last 100 years.
+
+:::
+
 ### Outside Days
 
 By default, DayPicker hides the days falling into the other months. Use `showOutsideDays` to display them.
diff --git a/website/docs/using-daypicker/navigation.mdx b/website/docs/using-daypicker/navigation.mdx
index a274dd845..f45eefb64 100644
--- a/website/docs/using-daypicker/navigation.mdx
+++ b/website/docs/using-daypicker/navigation.mdx
@@ -99,7 +99,7 @@ To prevent the user from navigating between months, set the `disableNavigation`
 
 ## Hiding the Navigation
 
-To hide the navigation completely, set the `hideNavigation` prop to `true`. Useful when setting the [caption layout](./customization.mdx#caption-layout) to `"dropdown"`.
+To hide the navigation completely, set the `hideNavigation` prop to `true`. Useful when setting the [caption layout](./customization.mdx#caption-layouts) to `"dropdown"`.
 
 | Prop Name        | Type      | Description              |
 | ---------------- | --------- | ------------------------ |


Test patch:
diff --git a/examples/Dropdown.test.tsx b/examples/Dropdown.test.tsx
index c4fc5cfb6..846c1245d 100644
--- a/examples/Dropdown.test.tsx
+++ b/examples/Dropdown.test.tsx
@@ -1,12 +1,12 @@
 import React from "react";
 
 import { grid, monthDropdown, yearDropdown } from "@/test/elements";
-import { render } from "@/test/render";
+import { render, within } from "@/test/render";
 import { user } from "@/test/user";
 
 import { Dropdown } from "./Dropdown";
 
-const today = new Date(2022, 5, 10);
+const today = new Date(2015, 6, 1);
 jest.useFakeTimers().setSystemTime(today);
 
 beforeEach(() => {
@@ -21,12 +21,43 @@ test("should display the year dropdown", () => {
   expect(yearDropdown()).toBeInTheDocument();
 });
 
+test("should disable the months out of range", () => {
+  expect(
+    within(monthDropdown()).getByRole("option", { name: "January" })
+  ).toBeDisabled();
+});
+
 describe("when choosing a month", () => {
-  const monthName = "January";
+  const monthName = "December";
   beforeEach(async () => {
     await user.selectOptions(monthDropdown(), monthName);
   });
   test("should display the month", () => {
-    expect(grid()).toHaveAccessibleName(`${monthName} 2022`);
+    expect(grid()).toHaveAccessibleName(`${monthName} 2015`);
+  });
+  test("should disable the years out of range", () => {
+    expect(
+      within(yearDropdown()).getByRole("option", { name: "2025" })
+    ).toBeDisabled();
+  });
+});
+
+describe("when choosing a year", () => {
+  const year = "2021";
+  beforeEach(async () => {
+    await user.selectOptions(yearDropdown(), year);
+  });
+  test("should display the year", () => {
+    expect(grid()).toHaveAccessibleName(`July ${year}`);
+  });
+});
+
+describe("when choosing a disabled month", () => {
+  const monthName = "February";
+  beforeEach(async () => {
+    await user.selectOptions(monthDropdown(), monthName);
+  });
+  test("should display the first available month", () => {
+    expect(grid()).toHaveAccessibleName(`July 2015`);
   });
 });
diff --git a/src/contexts/calendar.test.tsx b/src/contexts/calendar.test.tsx
index b7565b1d2..1e51637d1 100644
--- a/src/contexts/calendar.test.tsx
+++ b/src/contexts/calendar.test.tsx
@@ -39,7 +39,15 @@ describe("dropdown", () => {
     const { result } = renderHook(useCalendar, dayPicker);
     const months = result.current.dropdownOptions.months;
     expect(months).toHaveLength(12);
-    expect(months?.[0]).toEqual([0, "January"]);
-    expect(months?.[months.length - 1]).toEqual([11, "December"]);
+    expect(months?.[0]).toEqual({
+      value: 0,
+      label: "January",
+      disabled: false
+    });
+    expect(months?.[months.length - 1]).toEqual({
+      value: 11,
+      label: "December",
+      disabled: true
+    });
   });
 });
diff --git a/src/helpers/getDropdownMonths.test.ts b/src/helpers/getDropdownMonths.test.ts
index 313551760..7f679c4fd 100644
--- a/src/helpers/getDropdownMonths.test.ts
+++ b/src/helpers/getDropdownMonths.test.ts
@@ -24,37 +24,46 @@ test("returns sorted months between `fromDate` and `toDate`", () => {
   const fromDate = new Date(2023, 0, 1);
   const toDate = new Date(2023, 11, 31);
 
-  const result = getDropdownMonths({ fromDate, toDate, formatters });
+  const result = getDropdownMonths(
+    { fromDate, toDate, formatters },
+    fromDate.getFullYear()
+  );
 
   expect(result).toBeDefined();
   expect(result).toHaveLength(12);
   if (!result) throw new Error("Unexpected undefined result");
   for (let i = 0; i < result.length - 1; i++) {
-    expect(result[i][0]).toBeLessThan(result[i + 1][0]);
+    expect(result[i].value).toBeLessThan(result[i + 1].value);
   }
 });
 
 test("formats month labels correctly", () => {
   const fromDate = new Date(2023, 3, 1);
   const toDate = new Date(2023, 11, 31);
-  const result = getDropdownMonths({ fromDate, toDate, formatters });
+  const result = getDropdownMonths(
+    { fromDate, toDate, formatters },
+    fromDate.getFullYear()
+  );
   if (!result) throw new Error("Unexpected undefined result");
-  expect(result[0]).toEqual([3, "April"]);
-  expect(result[8]).toEqual([11, "December"]);
+  expect(result[0]).toEqual({ disabled: false, label: "April", value: 3 });
+  expect(result[8]).toEqual({ disabled: false, label: "December", value: 11 });
 });
 
 describe("when using a custom formatter", () => {
   test("formats month labels correctly", () => {
     const fromDate = new Date(2023, 0, 1);
     const toDate = new Date(2023, 11, 31);
-    const result = getDropdownMonths({
-      fromDate,
-      toDate,
-      formatters: {
-        formatMonthDropdown: (month) => `Month ${month.toString()}`
-      }
-    });
+    const result = getDropdownMonths(
+      {
+        fromDate,
+        toDate,
+        formatters: {
+          formatMonthDropdown: (month) => `Month ${month.toString()}`
+        }
+      },
+      fromDate.getFullYear()
+    );
     if (!result) throw new Error("Unexpected undefined result");
-    expect(result[0]).toEqual([0, "Month 0"]);
+    expect(result[0]).toEqual({ disabled: false, label: "Month 0", value: 0 });
   });
 });
diff --git a/src/helpers/getDropdownYears.test.ts b/src/helpers/getDropdownYears.test.ts
index 5e09e13d8..6daa7f141 100644
--- a/src/helpers/getDropdownYears.test.ts
+++ b/src/helpers/getDropdownYears.test.ts
@@ -33,9 +33,21 @@ it("returns an array of years between `fromDate` and `toDate`", () => {
   });
 
   expect(result).toEqual([
-    [2020, "Formatted 2020"],
-    [2021, "Formatted 2021"],
-    [2022, "Formatted 2022"]
+    {
+      disabled: false,
+      label: "Formatted 2020",
+      value: 2020
+    },
+    {
+      disabled: false,
+      label: "Formatted 2021",
+      value: 2021
+    },
+    {
+      disabled: false,
+      label: "Formatted 2022",
+      value: 2022
+    }
   ]);
 });
 
@@ -50,5 +62,11 @@ it("handles same year for fromDate and toDate", () => {
     }
   });
 
-  expect(result).toEqual([[2021, "Formatted 2021"]]);
+  expect(result).toEqual([
+    {
+      disabled: false,
+      label: "Formatted 2021",
+      value: 2021
+    }
+  ]);
 });
diff --git a/test/render.tsx b/test/render.tsx
index 2d0fdfe82..252b281c1 100644
--- a/test/render.tsx
+++ b/test/render.tsx
@@ -20,4 +20,4 @@ export function render(
   );
 }
 
-export { screen, act } from "@testing-library/react";
+export { screen, act, within } from "@testing-library/react";