Repository: Hacker0x01/react-datepicker
Pull request #4285 (2023-10-02 17:19:54)

fix(3635): a11y - Sets focus to input on select
# Fixes: https://github.com/Hacker0x01/react-datepicker/issues/3635 : [Accessibility issue - when date picker closes focus does not return on input](https://github.com/Hacker0x01/react-datepicker/issues/3635) 

### Issue
When using the `Escape` or `Enter` keys as well as after selection with pointer, focus did not return to input.

Accessibility [guidelines](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/) suggest that in these instances focus should return to the input.

### What were the specific changes made?

- Adds a `sendFocusBackToInput` method for handling returning focus. There were already two separate (nearly identical) implementations to handle this within `handleSelect` and `onPopperKeydown`. The `sendFocusBackToInput` is simply an abstraction to facilitate reuse in those instances as well as several others.
- Adds tests around possible consumer implementations.

### Additional notes/context
This work makes considerations not only for default implementations but as well as:
- `isClearable` - Clicking clear now returns focus to input
- `customInput` - This one Just Works ™️ as long as implemented as outlined in docs (ie with `forwardRef`)
- `shouldCloseOnSelect` - Don't hide calendar on date selection means we'll keep focus within picker
- `disabledKeyboardNavigation`- Pointer only
- `showTimeSelect` - When this prop is enabled the picker currently closes once a time is selected. This work just makes sure that focus only returns on that event and not on date selection.
- `inline` - Focus remains on picker
- `withPortal` - Again, Just works ™️ thanks to previous contributors. :) 
- Pressing `Escape` while in the input field - If picker was open and focus was on input, pressing `Escape` would send focus to `body`. Now returns to input.




https://github.com/Hacker0x01/react-datepicker/assets/78192/5c668e2e-4104-46af-8a64-768e0713889d




Patch:
diff --git a/package.json b/package.json
index cdbd08f7..ce0f5d73 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,7 @@
     "rollup-plugin-replace": "^2.2.0",
     "rollup-plugin-terser": "^7.0.2",
     "rollup-plugin-uglify": "^6.0.4",
-    "sass": "1.68.0",
+    "sass": "1.66.1",
     "sass-loader": "^13.3.2",
     "slugify": "^1.6.6",
     "style-loader": "^3.3.3",
diff --git a/src/index.jsx b/src/index.jsx
index 8884d549..9e1c86ba 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -302,6 +302,7 @@ export default class DatePicker extends React.Component {
   constructor(props) {
     super(props);
     this.state = this.calcInitialState();
+    this.preventFocusTimeout = null;
   }
 
   componentDidMount() {
@@ -463,6 +464,23 @@ export default class DatePicker extends React.Component {
     this.setState({ focused: true });
   };
 
+  sendFocusBackToInput = () => {
+    // Clear previous timeout if it exists
+    if (this.preventFocusTimeout) {
+      this.clearPreventFocusTimeout();
+    }
+
+    // close the popper and refocus the input
+    // stop the input from auto opening onFocus
+    // setFocus to the input
+    this.setState({ preventFocus: true }, () => {
+      this.preventFocusTimeout = setTimeout(() => {
+        this.setFocus();
+        this.setState({ preventFocus: false });
+      });
+    });
+  };
+
   cancelFocusInput = () => {
     clearTimeout(this.inputFocusTimeout);
     this.inputFocusTimeout = null;
@@ -543,15 +561,11 @@ export default class DatePicker extends React.Component {
   };
 
   handleSelect = (date, event, monthSelectedIn) => {
-    // Preventing onFocus event to fix issue
-    // https://github.com/Hacker0x01/react-datepicker/issues/628
-    this.setState({ preventFocus: true }, () => {
-      this.preventFocusTimeout = setTimeout(
-        () => this.setState({ preventFocus: false }),
-        50,
-      );
-      return this.preventFocusTimeout;
-    });
+    if (this.props.shouldCloseOnSelect && !this.props.showTimeSelect) {
+      // Preventing onFocus event to fix issue
+      // https://github.com/Hacker0x01/react-datepicker/issues/628
+      this.sendFocusBackToInput();
+    }
     if (this.props.onChangeRaw) {
       this.props.onChangeRaw(event);
     }
@@ -699,6 +713,7 @@ export default class DatePicker extends React.Component {
 
     this.props.onChange(changedDate);
     if (this.props.shouldCloseOnSelect) {
+      this.sendFocusBackToInput();
       this.setOpen(false);
     }
     if (this.props.showTimeInput) {
@@ -765,6 +780,7 @@ export default class DatePicker extends React.Component {
         }
       } else if (eventKey === "Escape") {
         event.preventDefault();
+        this.sendFocusBackToInput();
         this.setOpen(false);
       } else if (eventKey === "Tab") {
         this.setOpen(false);
@@ -875,24 +891,8 @@ export default class DatePicker extends React.Component {
   onPopperKeyDown = (event) => {
     const eventKey = event.key;
     if (eventKey === "Escape") {
-      // close the popper and refocus the input
-      // stop the input from auto opening onFocus
-      // close the popper
-      // setFocus to the input
-      // allow input auto opening onFocus
       event.preventDefault();
-      this.setState(
-        {
-          preventFocus: true,
-        },
-        () => {
-          this.setOpen(false);
-          setTimeout(() => {
-            this.setFocus();
-            this.setState({ preventFocus: false });
-          });
-        },
-      );
+      this.sendFocusBackToInput();
     }
   };
 
@@ -902,6 +902,9 @@ export default class DatePicker extends React.Component {
         event.preventDefault();
       }
     }
+
+    this.sendFocusBackToInput();
+
     if (this.props.selectsRange) {
       this.props.onChange([null, null], event);
     } else {
diff --git a/src/inputTime.jsx b/src/inputTime.jsx
index e6e84cd6..ffdf6999 100644
--- a/src/inputTime.jsx
+++ b/src/inputTime.jsx
@@ -31,14 +31,9 @@ export default class inputTime extends React.Component {
 
   onTimeChange = (time) => {
     this.setState({ time });
-
-    const { date: propDate } = this.props;
-    const isPropDateValid = propDate instanceof Date && !isNaN(propDate);
-    const date = isPropDateValid ? propDate : new Date();
-
+    const date = new Date();
     date.setHours(time.split(":")[0]);
     date.setMinutes(time.split(":")[1]);
-
     this.props.onChange(date);
   };
 
diff --git a/yarn.lock b/yarn.lock
index ab16a3b8..f132a239 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1162,7 +1162,7 @@
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
+"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
   version "7.23.0"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
   integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
@@ -7814,10 +7814,10 @@ sass-loader@^13.3.2:
   dependencies:
     neo-async "^2.6.2"
 
-sass@1.68.0:
-  version "1.68.0"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.68.0.tgz#0034b0cc9a50248b7d1702ac166fd25990023669"
-  integrity sha512-Lmj9lM/fef0nQswm1J2HJcEsBUba4wgNx2fea6yJHODREoMFnwRpZydBnX/RjyXw2REIwdkbqE4hrTo4qfDBUA==
+sass@1.66.1:
+  version "1.66.1"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.66.1.tgz#04b51c4671e4650aa393740e66a4e58b44d055b1"
+  integrity sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
     immutable "^4.0.0"
@@ -8410,11 +8410,10 @@ stylelint-config-standard@^34.0.0:
     stylelint-config-recommended "^13.0.0"
 
 stylelint-scss@^5.0.1, stylelint-scss@^5.1.0:
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-5.2.1.tgz#810299e4141fa38852bd14536a90e4942c8f387f"
-  integrity sha512-ZoTJUM85/qqpQHfEppjW/St//8s6p9Qsg8deWlYlr56F9iUgC9vXeIDQvH4odkRRJLTLFQzYMALSOFCQ3MDkgw==
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-5.1.0.tgz#dd318bc5c65f7a11f3ecacc7b6e8b67e7f2f1df1"
+  integrity sha512-E+KlQFXv1Euha43qw3q+wKBSli557wxbbo6/39DWhRNXlUa9Cz+FYrcgz+PT6ag0l6UisCYjAGCNhoSl4FcwlA==
   dependencies:
-    known-css-properties "^0.28.0"
     postcss-media-query-parser "^0.2.3"
     postcss-resolve-nested-selector "^0.1.1"
     postcss-selector-parser "^6.0.13"


Test patch:
diff --git a/test/datepicker_test.test.js b/test/datepicker_test.test.js
index 08ce3d36..ed659180 100644
--- a/test/datepicker_test.test.js
+++ b/test/datepicker_test.test.js
@@ -24,24 +24,6 @@ function getSelectedDayNode(datePicker) {
   );
 }
 
-function findSelectedDay(datePicker, targetDate) {
-  const days = TestUtils.scryRenderedComponentsWithType(datePicker, Day);
-  return days.find(
-    (d) =>
-      utils.formatDate(d.props.day, "yyyy-MM-dd") ===
-      utils.formatDate(targetDate, "yyyy-MM-dd"),
-  );
-}
-
-function goToLastMonth(datePicker) {
-  const lastMonthButton = TestUtils.scryRenderedDOMComponentsWithClass(
-    datePicker,
-    "react-datepicker__navigation-icon--previous",
-  )[0];
-
-  TestUtils.Simulate.click(ReactDOM.findDOMNode(lastMonthButton));
-}
-
 describe("DatePicker", () => {
   afterEach(() => {
     jest.resetAllMocks();
@@ -115,7 +97,7 @@ describe("DatePicker", () => {
     expect(datePicker.state.open).toBe(false);
   });
 
-  it("should close the popper and return focus to the date input.", (done) => {
+  it("should close the popper and return focus to the date input on Escape.", (done) => {
     // https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/datepicker-dialog.html
     // Date Picker Dialog | Escape | Closes the dialog and returns focus to the Choose Date button.
     var div = document.createElement("div");
@@ -141,6 +123,53 @@ describe("DatePicker", () => {
     });
   });
 
+  it("should close the popper and return focus to the date input on Enter.", (done) => {
+    // https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/datepicker-dialog.html
+    // Date Picker Dialog | Date Grid | Enter | Closes the dialog and returns focus to the Choose Date button.
+    var div = document.createElement("div");
+    document.body.appendChild(div);
+    var datePicker = ReactDOM.render(<DatePicker />, div);
+
+    // user focuses the input field, the calendar opens
+    var dateInput = div.querySelector("input");
+    TestUtils.Simulate.focus(dateInput);
+
+    // user may tab or arrow down to the current day (or some other element in the popper)
+    var today = div.querySelector(".react-datepicker__day--today");
+    today.focus();
+
+    // user hits Enter
+    TestUtils.Simulate.keyDown(today, getKey("Enter"));
+    defer(() => {
+      expect(datePicker.calendar).toBeFalsy();
+      expect(datePicker.state.preventFocus).toBe(false);
+      expect(document.activeElement).toBe(div.querySelector("input"));
+      done();
+    });
+  });
+
+  it("should not close the popper and keep focus on selected date if showTimeSelect is enabled.", (done) => {
+    var div = document.createElement("div");
+    document.body.appendChild(div);
+    var datePicker = ReactDOM.render(<DatePicker showTimeSelect />, div);
+
+    // user focuses the input field, the calendar opens
+    var dateInput = div.querySelector("input");
+    TestUtils.Simulate.focus(dateInput);
+
+    // user may tab or arrow down to the current day (or some other element in the popper)
+    var today = div.querySelector(".react-datepicker__day--today");
+    today.focus();
+
+    // user hits Enter
+    TestUtils.Simulate.keyDown(today, getKey("Enter"));
+    defer(() => {
+      expect(datePicker.calendar).toBeTruthy();
+      expect(document.activeElement).toBe(today);
+      done();
+    });
+  });
+
   it("should not re-focus the date input when focusing the year dropdown", () => {
     const onBlurSpy = jest.fn();
     const datePicker = mount(
@@ -232,6 +261,24 @@ describe("DatePicker", () => {
     expect(datePicker.state.open).toBe(true);
   });
 
+  it("should keep focus within calendar when clicking a day on the calendar and shouldCloseOnSelect prop is false", () => {
+    var div = document.createElement("div");
+    document.body.appendChild(div);
+    ReactDOM.render(<DatePicker shouldCloseOnSelect={false} />, div);
+
+    // user focuses the input field, the calendar opens
+    var dateInput = div.querySelector("input");
+    TestUtils.Simulate.focus(dateInput);
+
+    // user may tab or arrow down to the current day (or some other element in the popper)
+    var today = div.querySelector(".react-datepicker__day--today");
+    today.focus();
+
+    // user hits Enter
+    TestUtils.Simulate.keyDown(today, getKey("Enter"));
+    expect(document.activeElement).toBe(today);
+  });
+
   it("should set open to true if showTimeInput is true", () => {
     var datePicker = TestUtils.renderIntoDocument(
       <DatePicker shouldCloseOnSelect={false} showTimeInput />,
@@ -343,6 +390,23 @@ describe("DatePicker", () => {
     expect(datePicker.calendar).toBeFalsy();
   });
 
+  it("should hide the calendar and keep focus on input when pressing escape in the date input", (done) => {
+    var div = document.createElement("div");
+    document.body.appendChild(div);
+    var datePicker = ReactDOM.render(<DatePicker />, div);
+
+    // user focuses the input field, the calendar opens
+    var dateInput = div.querySelector("input");
+    TestUtils.Simulate.focus(dateInput);
+
+    TestUtils.Simulate.keyDown(dateInput, getKey("Escape"));
+    defer(() => {
+      expect(datePicker.calendar).toBeFalsy();
+      expect(document.activeElement).toBe(dateInput);
+      done();
+    });
+  });
+
   it("should hide the calendar when the pressing Shift + Tab in the date input", () => {
     var datePicker = TestUtils.renderIntoDocument(
       <DatePicker onBlur={onBlurSpy} />,
@@ -421,6 +485,26 @@ describe("DatePicker", () => {
     expect(datePicker.state.inputValue).toBeNull();
   });
 
+  it("should return focus to input when clear button is used", (done) => {
+    var div = document.createElement("div");
+    document.body.appendChild(div);
+    var datePicker = ReactDOM.render(
+      <DatePicker selected={utils.newDate("2015-12-15")} isClearable />,
+      div,
+    );
+
+    var clearButton = TestUtils.findRenderedDOMComponentWithClass(
+      datePicker,
+      "react-datepicker__close-icon",
+    );
+    TestUtils.Simulate.click(clearButton);
+
+    defer(() => {
+      expect(document.activeElement).toBe(div.querySelector("input"));
+      done();
+    });
+  });
+
   it("should set the title attribute on the clear button if clearButtonTitle is supplied", () => {
     const datePicker = TestUtils.renderIntoDocument(
       <DatePicker
@@ -1706,8 +1790,7 @@ describe("DatePicker", () => {
       const onChange = (dates = []) => {
         [startDate, endDate] = dates;
       };
-
-      const datePicker = TestUtils.renderIntoDocument(
+      let datePicker = TestUtils.renderIntoDocument(
         <DatePicker
           selected={selected}
           onChange={onChange}
@@ -1717,15 +1800,12 @@ describe("DatePicker", () => {
           inline
         />,
       );
-
-      let selectedDay = findSelectedDay(datePicker, selectedPrevious);
-      // Ensure that we're dealing with a date at the beginning of the month
-      if (!selectedDay) {
-        // If it's the beginning of the month & if the selectedPrevious is not being displayed, navigate to the previous month and reselect the selectedPrevious
-        goToLastMonth(datePicker);
-        selectedDay = findSelectedDay(datePicker, selectedPrevious);
-      }
-
+      let days = TestUtils.scryRenderedComponentsWithType(datePicker, Day);
+      const selectedDay = days.find(
+        (d) =>
+          utils.formatDate(d.props.day, "yyyy-MM-dd") ===
+          utils.formatDate(selectedPrevious, "yyyy-MM-dd"),
+      );
       TestUtils.Simulate.click(ReactDOM.findDOMNode(selectedDay));
       expect(utils.formatDate(startDate, "yyyy-MM-dd")).toBe(
         utils.formatDate(selectedPrevious, "yyyy-MM-dd"),
diff --git a/test/time_input_test.test.js b/test/time_input_test.test.js
index 32b16142..ee3c73cf 100644
--- a/test/time_input_test.test.js
+++ b/test/time_input_test.test.js
@@ -69,49 +69,4 @@ describe("timeInput", () => {
       new Date(new Date().setHours(14, 0, 0, 0)),
     );
   });
-
-  it("should trigger onChange event with the specified date prop if available", () => {
-    const mockOnChange = jest.fn();
-    const mockDate = new Date("2023-09-30");
-
-    const timeComponent = shallow(
-      <InputTimeComponent date={mockDate} onChange={mockOnChange} />,
-    );
-
-    const newTime = "13:00";
-    const input = timeComponent.find("input");
-    input.simulate("change", { target: { value: newTime } });
-
-    const expectedDate = new Date(mockDate);
-    const [expectedHours, expectedMinutes] = newTime.split(":");
-    expectedDate.setHours(expectedHours);
-    expectedDate.setMinutes(expectedMinutes);
-
-    expect(mockOnChange).toHaveBeenCalledWith(expectedDate);
-  });
-
-  it("should trigger onChange event with the default date when date prop is missing", () => {
-    const mockOnChange = jest.fn();
-    const mockCurrentDate = new Date("2023-09-30");
-    const dateSpy = jest
-      .spyOn(global, "Date")
-      .mockImplementation(() => mockCurrentDate);
-
-    const timeComponent = shallow(
-      <InputTimeComponent onChange={mockOnChange} />,
-    );
-
-    const newTime = "13:00";
-    const input = timeComponent.find("input");
-    input.simulate("change", { target: { value: newTime } });
-
-    const expectedDate = new Date(mockCurrentDate);
-    const [expectedHours, expectedMinutes] = newTime.split(":");
-    expectedDate.setHours(expectedHours);
-    expectedDate.setMinutes(expectedMinutes);
-
-    expect(mockOnChange).toHaveBeenCalledWith(expectedDate);
-
-    dateSpy.mockRestore();
-  });
 });
diff --git a/test/timepicker_test.test.js b/test/timepicker_test.test.js
index 3cfd5bee..b1d03082 100644
--- a/test/timepicker_test.test.js
+++ b/test/timepicker_test.test.js
@@ -1,4 +1,5 @@
 import React from "react";
+import defer from "lodash/defer";
 import DatePicker from "../src/index.jsx";
 import TestUtils from "react-dom/test-utils";
 import ReactDOM from "react-dom";
@@ -165,6 +166,21 @@ describe("TimePicker", () => {
     expect(getInputString()).toBe("February 28, 2018 12:30 AM");
   });
 
+  it("should return focus to input once time is selected", (done) => {
+    document.body.appendChild(div); // So we can check the dom later for activeElement
+    renderDatePicker("February 28, 2018 4:43 PM");
+    const dateInput = ReactDOM.findDOMNode(datePicker.input);
+    TestUtils.Simulate.focus(dateInput);
+    const time = TestUtils.findRenderedComponentWithType(datePicker, Time);
+    const lis = TestUtils.scryRenderedDOMComponentsWithTag(time, "li");
+    TestUtils.Simulate.keyDown(lis[1], getKey("Enter"));
+
+    defer(() => {
+      expect(document.activeElement).toBe(dateInput);
+      done();
+    });
+  });
+
   it("should not select time when Escape is pressed", () => {
     renderDatePicker("February 28, 2018 4:43 PM");
     TestUtils.Simulate.focus(ReactDOM.findDOMNode(datePicker.input));