Repository: Hacker0x01/react-datepicker
Pull request #4537 (2024-02-27 07:55:01)

Select multiple non-consecutive dates
Rework of #3999 that seems to be stalled.

- Added some tests on selected logic for multiple selected dates.
- Slightly refactored code to be more readable
- for more details see #3999 




Patch:
diff --git a/docs-site/src/components/Examples/index.js b/docs-site/src/components/Examples/index.js
index 332bd14d..fef9e6f2 100644
--- a/docs-site/src/components/Examples/index.js
+++ b/docs-site/src/components/Examples/index.js
@@ -101,6 +101,7 @@ import selectsRangeWithDisabledDates from "../../examples/selectsRangeWithDisabl
 import CalendarStartDay from "../../examples/calendarStartDay";
 import ExternalForm from "../../examples/externalForm";
 import CalendarIcon from "../../examples/calendarIcon";
+import SelectsMultiple from "../../examples/selectsMultiple";
 import CalendarIconExternal from "../../examples/calendarIconExternal";
 import CalendarIconSvgIcon from "../../examples/calendarIconSvgIcon";
 import ToggleCalendarOnIconClick from "../../examples/toggleCalendarOnIconClick";
@@ -484,6 +485,10 @@ export default class exampleComponents extends React.Component {
       title: "Specific Time Range",
       component: ExcludeTimePeriod,
     },
+    {
+      title: "Select multiple dates",
+      component: SelectsMultiple,
+    },
     {
       title: "Strict parsing",
       component: StrictParsing,
diff --git a/docs-site/src/examples/selectsMultiple.js b/docs-site/src/examples/selectsMultiple.js
new file mode 100644
index 00000000..48485055
--- /dev/null
+++ b/docs-site/src/examples/selectsMultiple.js
@@ -0,0 +1,15 @@
+() => {
+  const [selectedDates, setSelectedDates] = useState([new Date()]);
+  const onChange = (dates) => {
+    setSelectedDates(dates);
+  };
+  return (
+    <DatePicker
+      selectedDates={selectedDates}
+      selectsMultiple
+      onChange={onChange}
+      shouldCloseOnSelect={false}
+      disabledKeyboardNavigation
+    />
+  );
+};
diff --git a/src/calendar.jsx b/src/calendar.jsx
index 0e5795b7..e9f704bd 100644
--- a/src/calendar.jsx
+++ b/src/calendar.jsx
@@ -162,6 +162,8 @@ export default class Calendar extends React.Component {
     selectsStart: PropTypes.bool,
     selectsRange: PropTypes.bool,
     selectsDisabledDaysInRange: PropTypes.bool,
+    selectsMultiple: PropTypes.bool,
+    selectedDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
     showMonthDropdown: PropTypes.bool,
     showPreviousMonths: PropTypes.bool,
     showMonthYearDropdown: PropTypes.bool,
@@ -921,6 +923,8 @@ export default class Calendar extends React.Component {
             selectsEnd={this.props.selectsEnd}
             selectsRange={this.props.selectsRange}
             selectsDisabledDaysInRange={this.props.selectsDisabledDaysInRange}
+            selectsMultiple={this.props.selectsMultiple}
+            selectedDates={this.props.selectedDates}
             showWeekNumbers={this.props.showWeekNumbers}
             startDate={this.props.startDate}
             endDate={this.props.endDate}
diff --git a/src/date_utils.js b/src/date_utils.js
index 1a6cd81b..e35b459b 100644
--- a/src/date_utils.js
+++ b/src/date_utils.js
@@ -197,6 +197,23 @@ export function safeDateRangeFormat(startDate, endDate, props) {
   return `${formattedStartDate} - ${formattedEndDate}`;
 }
 
+export function safeMultipleDatesFormat(dates, props) {
+  if (!dates?.length) {
+    return "";
+  }
+  const formattedFirstDate = safeDateFormat(dates[0], props);
+  if (dates.length === 1) {
+    return formattedFirstDate;
+  }
+  if (dates.length === 2) {
+    const formattedSecondDate = safeDateFormat(dates[1], props);
+    return `${formattedFirstDate}, ${formattedSecondDate}`;
+  }
+
+  const extraDatesCount = dates.length - 1;
+  return `${formattedFirstDate} (+${extraDatesCount})`;
+}
+
 // ** Date Setters **
 
 export function setTime(date, { hour = 0, minute = 0, second = 0 }) {
diff --git a/src/day.jsx b/src/day.jsx
index 9632ee94..2861edb1 100644
--- a/src/day.jsx
+++ b/src/day.jsx
@@ -42,6 +42,8 @@ export default class Day extends React.Component {
     showWeekPicker: PropTypes.bool,
     showWeekNumber: PropTypes.bool,
     selectsDisabledDaysInRange: PropTypes.bool,
+    selectsMultiple: PropTypes.bool,
+    selectedDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
     startDate: PropTypes.instanceOf(Date),
     renderDayContents: PropTypes.func,
     handleOnKeyDown: PropTypes.func,
@@ -93,14 +95,17 @@ export default class Day extends React.Component {
 
   isSameDay = (other) => isSameDay(this.props.day, other);
 
-  isKeyboardSelected = () =>
-    !this.props.disabledKeyboardNavigation &&
-    !(
-      this.isSameDay(this.props.selected) ||
-      this.isSameWeek(this.props.selected)
-    ) &&
-    (this.isSameDay(this.props.preSelection) ||
-      this.isSameWeek(this.props.preSelection));
+  isKeyboardSelected = () => {
+    if (this.props.disabledKeyboardNavigation) {
+      return false;
+    }
+
+    const isSelectedDate = this.props.selectsMultiple
+      ? this.props.selectedDates?.some((date) => this.isSameDayOrWeek(date))
+      : this.isSameDayOrWeek(this.props.selected);
+
+    return !isSelectedDate && this.isSameDayOrWeek(this.props.preSelection);
+  };
 
   isDisabled = () => isDayDisabled(this.props.day, this.props);
 
@@ -127,6 +132,8 @@ export default class Day extends React.Component {
       ),
     );
 
+  isSameDayOrWeek = (other) => this.isSameDay(other) || this.isSameWeek(other);
+
   getHighLightedClass = () => {
     const { day, highlightDates } = this.props;
 
@@ -276,8 +283,14 @@ export default class Day extends React.Component {
 
   isCurrentDay = () => this.isSameDay(newDate());
 
-  isSelected = () =>
-    this.isSameDay(this.props.selected) || this.isSameWeek(this.props.selected);
+  isSelected = () => {
+    if (this.props.selectsMultiple) {
+      return this.props.selectedDates?.some((date) =>
+        this.isSameDayOrWeek(date),
+      );
+    }
+    return this.isSameDayOrWeek(this.props.selected);
+  };
 
   getClassNames = (date) => {
     const dayClassName = this.props.dayClassName
diff --git a/src/index.jsx b/src/index.jsx
index 6e618987..daa1462f 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -47,6 +47,7 @@ import {
   isSameDay,
   isMonthDisabled,
   isYearDisabled,
+  safeMultipleDatesFormat,
   getHolidaysMap,
   isDateBefore,
 } from "./date_utils";
@@ -232,6 +233,8 @@ export default class DatePicker extends React.Component {
     selectsStart: PropTypes.bool,
     selectsRange: PropTypes.bool,
     selectsDisabledDaysInRange: PropTypes.bool,
+    selectsMultiple: PropTypes.bool,
+    selectedDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
     showMonthDropdown: PropTypes.bool,
     showPreviousMonths: PropTypes.bool,
     showMonthYearDropdown: PropTypes.bool,
@@ -623,12 +626,20 @@ export default class DatePicker extends React.Component {
       }
     }
 
-    const { onChange, selectsRange, startDate, endDate } = this.props;
+    const {
+      onChange,
+      selectsRange,
+      startDate,
+      endDate,
+      selectsMultiple,
+      selectedDates,
+    } = this.props;
 
     if (
       !isEqual(this.props.selected, changedDate) ||
       this.props.allowSameDay ||
-      selectsRange
+      selectsRange ||
+      selectsMultiple
     ) {
       if (changedDate !== null) {
         if (
@@ -669,6 +680,24 @@ export default class DatePicker extends React.Component {
         if (isRangeFilled) {
           onChange([changedDate, null], event);
         }
+      } else if (selectsMultiple) {
+        if (!selectedDates?.length) {
+          onChange([changedDate], event);
+        } else {
+          const isChangedDateAlreadySelected = selectedDates.some(
+            (selectedDate) => isSameDay(selectedDate, changedDate),
+          );
+
+          if (isChangedDateAlreadySelected) {
+            const nextDates = selectedDates.filter(
+              (selectedDate) => !isSameDay(selectedDate, changedDate),
+            );
+
+            onChange(nextDates, event);
+          } else {
+            onChange([...selectedDates, changedDate], event);
+          }
+        }
       } else {
         onChange(changedDate, event);
       }
@@ -1018,6 +1047,8 @@ export default class DatePicker extends React.Component {
         selectsStart={this.props.selectsStart}
         selectsEnd={this.props.selectsEnd}
         selectsRange={this.props.selectsRange}
+        selectsMultiple={this.props.selectsMultiple}
+        selectedDates={this.props.selectedDates}
         startDate={this.props.startDate}
         endDate={this.props.endDate}
         excludeDates={this.props.excludeDates}
@@ -1201,7 +1232,9 @@ export default class DatePicker extends React.Component {
                 this.props.endDate,
                 this.props,
               )
-            : safeDateFormat(this.props.selected, this.props);
+            : this.props.selectsMultiple
+              ? safeMultipleDatesFormat(this.props.selectedDates, this.props)
+              : safeDateFormat(this.props.selected, this.props);
 
     return React.cloneElement(customInput, {
       [customInputRef]: (input) => {
@@ -1242,10 +1275,14 @@ export default class DatePicker extends React.Component {
       clearButtonTitle,
       clearButtonClassName = "",
       ariaLabelClose = "Close",
+      selectedDates,
     } = this.props;
     if (
       isClearable &&
-      (selected != null || startDate != null || endDate != null)
+      (selected != null ||
+        startDate != null ||
+        endDate != null ||
+        selectedDates?.length)
     ) {
       return (
         <button
diff --git a/src/month.jsx b/src/month.jsx
index 88092d3d..69845196 100644
--- a/src/month.jsx
+++ b/src/month.jsx
@@ -99,6 +99,8 @@ export default class Month extends React.Component {
     selectsStart: PropTypes.bool,
     selectsRange: PropTypes.bool,
     selectsDisabledDaysInRange: PropTypes.bool,
+    selectsMultiple: PropTypes.bool,
+    selectedDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
     showWeekNumbers: PropTypes.bool,
     startDate: PropTypes.instanceOf(Date),
     setOpen: PropTypes.func,
@@ -331,6 +333,8 @@ export default class Month extends React.Component {
           selectsEnd={this.props.selectsEnd}
           selectsRange={this.props.selectsRange}
           selectsDisabledDaysInRange={this.props.selectsDisabledDaysInRange}
+          selectsMultiple={this.props.selectsMultiple}
+          selectedDates={this.props.selectedDates}
           showWeekNumber={this.props.showWeekNumbers}
           showWeekPicker={this.props.showWeekPicker}
           startDate={this.props.startDate}
diff --git a/src/week.jsx b/src/week.jsx
index 22e7e8e9..3288e72b 100644
--- a/src/week.jsx
+++ b/src/week.jsx
@@ -52,6 +52,8 @@ export default class Week extends React.Component {
     selectsEnd: PropTypes.bool,
     selectsStart: PropTypes.bool,
     selectsRange: PropTypes.bool,
+    selectsMultiple: PropTypes.bool,
+    selectedDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
     selectsDisabledDaysInRange: PropTypes.bool,
     showWeekNumber: PropTypes.bool,
     showWeekPicker: PropTypes.bool,
@@ -166,6 +168,8 @@ export default class Week extends React.Component {
             showWeekPicker={this.props.showWeekPicker}
             showWeekNumber={this.props.showWeekNumber}
             selectsDisabledDaysInRange={this.props.selectsDisabledDaysInRange}
+            selectsMultiple={this.props.selectsMultiple}
+            selectedDates={this.props.selectedDates}
             startDate={this.props.startDate}
             endDate={this.props.endDate}
             dayClassName={this.props.dayClassName}


Test patch:
diff --git a/test/datepicker_test.test.js b/test/datepicker_test.test.js
index 38ee90b3..da87c65b 100644
--- a/test/datepicker_test.test.js
+++ b/test/datepicker_test.test.js
@@ -1851,6 +1851,97 @@ describe("DatePicker", () => {
     expect(datePicker.state.open).toBe(true);
   });
 
+  describe("multiSelecting non-consecutive dates", () => {
+    const testDate = new Date("2024-02-02 00:00:00");
+
+    beforeAll(() => {
+      jest.useFakeTimers();
+      jest.setSystemTime(testDate);
+    });
+
+    afterAll(() => {
+      jest.useRealTimers();
+    });
+
+    it("should return array of dates to onChange callback when day is selected in multiSelect mode", () => {
+      const onChange = jest.fn();
+
+      var datePicker = TestUtils.renderIntoDocument(
+        <DatePicker
+          shouldCloseOnSelect={false}
+          selectsMultiple
+          onChange={onChange}
+        />,
+      );
+      var dateInput = datePicker.input;
+      var node = findDOMNode(dateInput);
+      TestUtils.Simulate.focus(node);
+      var days = TestUtils.scryRenderedComponentsWithType(
+        datePicker.calendar,
+        Day,
+      );
+
+      // Might seem odd, but the first couple of days of the calendar is in january, so days[5] is february 2nd
+      TestUtils.Simulate.click(findDOMNode(days[5]));
+      expect(onChange).toHaveBeenCalled();
+      expect(onChange).toHaveBeenCalledWith([testDate], expect.anything());
+    });
+
+    it("should remove previously selected date from array if date is selected again", () => {
+      const onChange = jest.fn();
+      const anotherDate = new Date("2024-01-01");
+      var datePicker = TestUtils.renderIntoDocument(
+        <DatePicker
+          shouldCloseOnSelect={false}
+          selectsMultiple
+          selectedDates={[anotherDate, testDate]}
+          onChange={onChange}
+        />,
+      );
+      var dateInput = datePicker.input;
+      var node = findDOMNode(dateInput);
+      TestUtils.Simulate.focus(node);
+      var days = TestUtils.scryRenderedComponentsWithType(
+        datePicker.calendar,
+        Day,
+      );
+
+      // Might seem odd, but the first couple of days of the calendar is in january, so days[5] is february 2nd
+      TestUtils.Simulate.click(findDOMNode(days[5]));
+      expect(onChange).toHaveBeenCalled();
+      expect(onChange).toHaveBeenCalledWith([anotherDate], expect.anything());
+    });
+
+    it("should add newly selected date to array of selected dates", () => {
+      const onChange = jest.fn();
+      const previouslyAddedDate = new Date("2024-01-01");
+
+      var datePicker = TestUtils.renderIntoDocument(
+        <DatePicker
+          shouldCloseOnSelect={false}
+          selectsMultiple
+          selectedDates={[previouslyAddedDate]}
+          onChange={onChange}
+        />,
+      );
+      var dateInput = datePicker.input;
+      var node = findDOMNode(dateInput);
+      TestUtils.Simulate.focus(node);
+      var days = TestUtils.scryRenderedComponentsWithType(
+        datePicker.calendar,
+        Day,
+      );
+
+      // Might seem odd, but the first couple of days of the calendar is in january, so days[5] is february 2nd
+      TestUtils.Simulate.click(findDOMNode(days[5]));
+      expect(onChange).toHaveBeenCalled();
+      expect(onChange).toHaveBeenCalledWith(
+        [previouslyAddedDate, testDate],
+        expect.anything(),
+      );
+    });
+  });
+
   describe("selectsRange with inline", () => {
     it("should change dates of range when dates are empty", () => {
       const selected = utils.newDate();
diff --git a/test/day_test.test.js b/test/day_test.test.js
index 0d966a85..be98cc85 100644
--- a/test/day_test.test.js
+++ b/test/day_test.test.js
@@ -83,6 +83,14 @@ describe("Day", () => {
           .getAttribute("aria-selected");
         expect(ariaSelected).toBe("true");
       });
+
+      it("should apply the selected class for selectedDates", () => {
+        const shallowDay = renderDay(day, {
+          selectsMultiple: true,
+          selectedDates: [day],
+        });
+        expect(shallowDay.hasClass(className)).toBe(true);
+      });
     });
 
     describe("if not selected", () => {
@@ -115,12 +123,33 @@ describe("Day", () => {
       expect(shallowDay.hasClass(className)).toBe(true);
     });
 
+    it("should apply the keyboard-selected class when pre-selected and another days is multi-selected", () => {
+      const day = newDate();
+      const selected = addDays(day, 1);
+      const shallowDay = renderDay(day, {
+        selectedDates: [selected],
+        selectsMultiple: true,
+        preSelection: day,
+      });
+      expect(shallowDay.hasClass(className)).toBe(true);
+    });
+
     it("should not apply the keyboard-selected class when selected", () => {
       const day = newDate();
       const shallowDay = renderDay(day, { selected: day, preSelection: day });
       expect(shallowDay.hasClass(className)).toBe(false);
     });
 
+    it("should not apply the keyboard-selected class when selected a multi-selected day", () => {
+      const day = newDate();
+      const shallowDay = renderDay(day, {
+        selectedDates: [day],
+        selectsMultiple: true,
+        preSelection: day,
+      });
+      expect(shallowDay.hasClass(className)).toBe(false);
+    });
+
     it("should not apply the keyboard-selected class when another day is pre-selected", () => {
       const day = newDate();
       const selected = addDays(day, 1);
diff --git a/test/multiple_selected_dates.test.js b/test/multiple_selected_dates.test.js
new file mode 100644
index 00000000..ca37b338
--- /dev/null
+++ b/test/multiple_selected_dates.test.js
@@ -0,0 +1,59 @@
+import React from "react";
+import DatePicker from "../src";
+import { render } from "@testing-library/react";
+
+describe("Multiple Dates Selected", function () {
+  function getDatePicker(extraProps) {
+    return render(
+      <DatePicker
+        selectsMultiple
+        onChange={() => {}}
+        shouldCloseOnSelect={false}
+        disabledKeyboardNavigation
+        {...extraProps}
+      />,
+    );
+  }
+
+  it("should handle text format for no selected date", () => {
+    const datePicker = getDatePicker({
+      selectsMultiple: true,
+      selectedDates: [],
+    });
+
+    expect(datePicker.getByRole("textbox").value).toBe("");
+  });
+
+  it("should handle text format for one selected date", () => {
+    const datePicker = getDatePicker({
+      selectsMultiple: true,
+      selectedDates: [new Date("2024/01/01")],
+    });
+
+    expect(datePicker.getByRole("textbox").value).toBe("01/01/2024");
+  });
+
+  it("should handle text format for two selected dates", () => {
+    const datePicker = getDatePicker({
+      selectsMultiple: true,
+      selectedDates: [new Date("2024/01/01"), new Date("2024/01/15")],
+    });
+
+    expect(datePicker.getByRole("textbox").value).toBe(
+      "01/01/2024, 01/15/2024",
+    );
+  });
+
+  it("should handle text format for more than two selected dates", () => {
+    const datePicker = getDatePicker({
+      selectsMultiple: true,
+      selectedDates: [
+        new Date("2024/01/01"),
+        new Date("2024/01/15"),
+        new Date("2024/03/15"),
+      ],
+    });
+
+    expect(datePicker.getByRole("textbox").value).toBe("01/01/2024 (+2)");
+  });
+});