Repository: Hacker0x01/react-datepicker (ts)

Issue:
Select multiple dates
**Is your feature request related to a problem? Please describe.**
It's not a problem, it's a use-case. In our platform our users should be able to select multiple dates (not a range, but an array of dates).

**Describe the solution you'd like**
To have an option to select multiple dates.

**Describe alternatives you've considered**
Of course there are other packages we can use but we were thinking about using this package since it looks modern and allows us to get rid of moment.

Pull request:
Support selecting multiple dates
https://user-images.githubusercontent.com/13172299/232141144-be6b247e-b7f4-4b7b-8199-69ea75dcc936.mp4

# What

Closes #3157 

This PR adds 2 props which allow users to select multiple isolated dates on the calendar picker.

You can now select multiple dates at once, which is very convenient for products that need users to select multiple dates, such as Calendly and the like.

# How

Following the API for `selectsRange`, this PR adds a `selectsMultiple` boolean, in addition to a `selectedDates` array.

I thought it was best for `selectedDates` to be explicitly different from `selected`. This is also following the convention from `selectsRange`, which uses `startDate` and `endDate`.

## Example

As shown in the video above, here is the example code:

```tsx
() => {
  const [selectedDates, setSelectedDates] = useState([new Date()]);
  const onChange = (dates) => {
    setSelectedDates(dates);
  }
  return (
    <DatePicker
      selectedDates={selectedDates}
      selectsMultiple
      onChange={onChange}
      shouldCloseOnSelect={false}
      disabledKeyboardNavigation
    />
  )
}
```

## Breakdown

This PR adds a few minor changes to achieve adding multiple dates.

First, in the `Picker` component, it adds 2 core changes:

1. Formatting the input with `safeMultipleDatesFormat`.
2. Properly accounting for selecting multiple dates in `onChange`.

The rest is just prop drilling down to `<Day />`, which has 2 key changes:

1. `isKeyboardSelected` needs to account for `selectedDates` rather than `selected`.
2. `isSelected` also checks for `selectedDates`

I've tested this out and it all looks good to me!

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 0cad3689..e6e63970 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,
@@ -920,6 +922,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 3ed81375..a81f60f6 100644
--- a/src/date_utils.js
+++ b/src/date_utils.js
@@ -185,6 +185,23 @@ export function safeDateRangeFormat(startDate, endDate, props) {
   return `${formattedStartDate} - ${formattedEndDate}`;
 }
 
+export function safeMultipleDatesFormat(dates, props) {
+  if (!dates || !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..95605b72 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,25 @@ 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;
+    }
+    if (this.props.selectsMultiple) {
+      const isSelectedDate = (this.props.selectedDates || []).some((date) =>
+        this.isSameDay(date)
+      );
+      return !isSelectedDate && this.isSameDay(this.props.preSelection);
+    } else {
+      return !(
+        this.isSameDay(this.props.selected) ||
+        this.isSameWeek(this.props.selected)
+      ) && (
+        this.isSameDay(this.props.preSelection) ||
+        this.isSameWeek(this.props.preSelection)
+      );
+    }
+  };
 
   isDisabled = () => isDayDisabled(this.props.day, this.props);
 
@@ -275,9 +288,13 @@ 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 && this.props.selectedDates) {
+      return this.props.selectedDates.some((date) => this.isSameDay(date));
+    }
+    return this.isSameDay(this.props.selected) || this.isSameWeek(this.props.selected);
+  }; 
 
   getClassNames = (date) => {
     const dayClassName = this.props.dayClassName
diff --git a/src/index.jsx b/src/index.jsx
index a3700220..e79ab3e1 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -45,6 +45,7 @@ import {
   isSameDay,
   isMonthDisabled,
   isYearDisabled,
+  safeMultipleDatesFormat,
   getHolidaysMap,
   isDateBefore,
 } from "./date_utils";
@@ -230,6 +231,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,
@@ -621,12 +624,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 (
@@ -667,6 +678,26 @@ export default class DatePicker extends React.Component {
         if (isRangeFilled) {
           onChange([changedDate, null], event);
         }
+      } else if (selectsMultiple) {
+        const noSelectedDates = !selectedDates || selectedDates.length === 0;
+
+        if (noSelectedDates) {
+          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);
       }
@@ -1011,6 +1042,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}
@@ -1194,6 +1227,8 @@ export default class DatePicker extends React.Component {
             this.props.endDate,
             this.props,
           )
+        : this.props.selectsMultiple
+        ? safeMultipleDatesFormat(this.props.selectedDates, this.props)
         : safeDateFormat(this.props.selected, this.props);
 
     return React.cloneElement(customInput, {
diff --git a/src/month.jsx b/src/month.jsx
index c97979de..b4e3dee9 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,
@@ -330,6 +332,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/multiple_selected_dates.test.js b/test/multiple_selected_dates.test.js
new file mode 100644
index 00000000..0fcdb792
--- /dev/null
+++ b/test/multiple_selected_dates.test.js
@@ -0,0 +1,36 @@
+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 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)");
+  });
+});