Skip to content

Commit

Permalink
Adds keyboard navigation for month picker (Hacker0x01#2389)
Browse files Browse the repository at this point in the history
* feat: adds basic keyboard navigation to month picker

* fix: set focus when navigating through keyboard

* feat: add aria label to months

* fix: prevent navigation to disabed/excluded months

* tests: Tests added for new Month Picker feature

* docs: updates readme with new keybinds

* test: test coverage improved

* Update src/month.jsx

Co-authored-by: Jonas Antonelli <[email protected]>

Co-authored-by: Jonas Antonelli <[email protected]>
  • Loading branch information
camilaazuma and jonasantonelli authored Sep 14, 2020
1 parent dac2214 commit 73faa21
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 1 deletion.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ The examples are hosted within the docs folder and are ran in the simple app tha
- _End_: Move to the next year.
- _Enter/Esc/Tab_: close the calendar. (Enter & Esc calls preventDefault)

#### For month picker

- _Left_: Move to the previous month.
- _Right_: Move to the next month.
- _Enter_: Select date and close the calendar

## License

Copyright (c) 2019 HackerOne Inc. and individual contributors. Licensed under MIT license, see [LICENSE](LICENSE) for the full license.
1 change: 1 addition & 0 deletions src/calendar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ export default class Calendar extends React.Component {
fixedHeight={this.props.fixedHeight}
filterDate={this.props.filterDate}
preSelection={this.props.preSelection}
setPreSelection={this.props.setPreSelection}
selected={this.props.selected}
selectsStart={this.props.selectsStart}
selectsEnd={this.props.selectsEnd}
Expand Down
67 changes: 66 additions & 1 deletion src/month.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default class Month extends React.Component {
onWeekSelect: PropTypes.func,
peekNextMonth: PropTypes.bool,
preSelection: PropTypes.instanceOf(Date),
setPreSelection: PropTypes.func,
selected: PropTypes.instanceOf(Date),
selectingDate: PropTypes.instanceOf(Date),
selectsEnd: PropTypes.bool,
Expand All @@ -57,6 +58,12 @@ export default class Month extends React.Component {
])
};

MONTH_REFS = Array(12).fill().map(() => React.createRef());

isDisabled = date => utils.isDayDisabled(date, this.props);

isExcluded = date => utils.isDayExcluded(date, this.props);

handleDayClick = (day, event) => {
if (this.props.onDayClick) {
this.props.onDayClick(day, event, this.props.orderInDisplay);
Expand Down Expand Up @@ -196,6 +203,30 @@ export default class Month extends React.Component {
);
};

handleMonthNavigation = (newMonth, newDate) => {
if(this.isDisabled(newDate) || this.isExcluded(newDate)) return;
this.props.setPreSelection(newDate);
this.MONTH_REFS[newMonth].current && this.MONTH_REFS[newMonth].current.focus();
}

onMonthKeyDown = (event, month) => {
const eventKey = event.key;
if (!this.props.disabledKeyboardNavigation) {
switch (eventKey) {
case "Enter":
this.onMonthClick(event, month);
this.props.setPreSelection(this.props.selected);
break;
case "ArrowRight":
this.handleMonthNavigation(month === 11 ? 0 : month+1, utils.addMonths(this.props.preSelection, 1));
break;
case "ArrowLeft":
this.handleMonthNavigation(month === 0 ? 11 : month-1, utils.subMonths(this.props.preSelection, 1));
break;
}
}
};

onQuarterClick = (e, q) => {
this.handleDayClick(
utils.getStartOfQuarter(utils.setQuarter(this.props.day, q)),
Expand All @@ -204,7 +235,7 @@ export default class Month extends React.Component {
};

getMonthClassNames = m => {
const { day, startDate, endDate, selected, minDate, maxDate } = this.props;
const { day, startDate, endDate, selected, minDate, maxDate, preSelection } = this.props;
return classnames(
"react-datepicker__month-text",
`react-datepicker__month-${m}`,
Expand All @@ -215,6 +246,7 @@ export default class Month extends React.Component {
"react-datepicker__month--selected":
utils.getMonth(day) === m &&
utils.getYear(day) === utils.getYear(selected),
"react-datepicker__month-text--keyboard-selected": utils.getMonth(preSelection) === m,
"react-datepicker__month--in-range": utils.isMonthinRange(
startDate,
endDate,
Expand All @@ -227,6 +259,32 @@ export default class Month extends React.Component {
);
};

getTabIndex = (m) => {
const preSelectedMonth = utils.getMonth(this.props.preSelection);
const tabIndex =
!this.props.disabledKeyboardNavigation && m === preSelectedMonth
? "0"
: "-1";

return tabIndex;
};

getAriaLabel = month => {
const {
ariaLabelPrefix = "Choose",
disabledDayAriaLabelPrefix = "Not available",
day
} = this.props;

const labelDate = utils.setMonth(day, month)
const prefix =
this.isDisabled(labelDate) || this.isExcluded(labelDate)
? disabledDayAriaLabelPrefix
: ariaLabelPrefix;

return `${prefix} ${utils.formatDate(labelDate, "MMMM yyyy")}`;
};

getQuarterClassNames = q => {
const { day, startDate, endDate, selected, minDate, maxDate } = this.props;
return classnames(
Expand Down Expand Up @@ -278,11 +336,18 @@ export default class Month extends React.Component {
<div className="react-datepicker__month-wrapper" key={i}>
{month.map((m, j) => (
<div
ref={this.MONTH_REFS[m]}
key={j}
onClick={ev => {
this.onMonthClick(ev, m);
}}
onKeyDown={ev => {
this.onMonthKeyDown(ev, m);
}}
tabIndex={this.getTabIndex(m)}
className={this.getMonthClassNames(m)}
role="button"
aria-label={this.getAriaLabel(m)}
>
{showFullMonthYearPicker
? utils.getMonthInLocale(m, locale)
Expand Down
221 changes: 221 additions & 0 deletions test/month_test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import React from "react";
import ReactDOM from "react-dom";
import Month from "../src/month";
import Day from "../src/day";
import range from "lodash/range";
import { mount, shallow } from "enzyme";
import * as utils from "../src/date_utils";
import TestUtils from "react-dom/test-utils";

function getKey(key) {
switch (key) {
case "Tab":
return { key, code: 9, which: 9 };
case "Enter":
return { key, code: 13, which: 13 };
case "ArrowLeft":
return { key, code: 37, which: 37 };
case "ArrowRight":
return { key, code: 39, which: 39 };
}
throw new Error("Unknown key :" + key);
}


describe("Month", () => {
function assertDateRangeInclusive(month, start, end) {
const dayCount = utils.getDaysDiff(end, start) + 1;
Expand Down Expand Up @@ -291,4 +307,209 @@ describe("Month", () => {
true
);
});

it("should render full month name", () => {
const monthComponent = mount(
<Month
day={utils.newDate("2015-12-01")}
showMonthYearPicker
showFullMonthYearPicker
/>
);
const month = monthComponent.find(".react-datepicker__month-1").at(0);

expect(month.text()).to.equal('February');
});

it("should render short month name", () => {
const monthComponent = mount(
<Month
day={utils.newDate("2015-12-01")}
showMonthYearPicker
/>
);
const month = monthComponent.find(".react-datepicker__month-1").at(0);

expect(month.text()).to.equal('Feb');
});

describe("Keyboard navigation", () => {
const renderMonth = (props) => shallow(<Month showMonthYearPicker {...props} />);

it("should trigger setPreSelection and set March as pre-selected on arrowRight", () => {
let preSelected = false;
const setPreSelection = param => {
preSelected = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-02-01"),
day: utils.newDate("2015-02-01"),
setPreSelection: setPreSelection,
preSelection: utils.newDate("2015-02-01"),
});
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("Tab"));
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowRight"));

expect(preSelected.toString()).to.equal(utils.newDate("2015-03-01").toString());
});

it("should trigger setPreSelection and set January as pre-selected on arrowLeft", () => {
let preSelected = false;
const setPreSelection = param => {
preSelected = param;
}
const monthComponent = renderMonth({
selected: utils.newDate("2015-02-01"),
day: utils.newDate("2015-02-01"),
setPreSelection: setPreSelection,
preSelection: utils.newDate("2015-02-01"),
});
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowLeft"));

expect(preSelected.toString()).to.equal(utils.newDate("2015-01-01").toString());
});

it("should select March when Enter is pressed", () => {
let preSelected = false;
let selectedDate = null;
const setPreSelection = () => {
preSelected = true;
}
const setSelectedDate = param => {
selectedDate = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-02-01"),
day: utils.newDate("2015-02-01"),
setPreSelection: setPreSelection,
preSelection: utils.newDate("2015-02-01"),
onDayClick: setSelectedDate
});

monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowLeft"));
monthComponent.find(".react-datepicker__month-2").simulate('keydown', getKey("Enter"));

expect(preSelected).to.equal(true);
expect(selectedDate.toString()).to.equal(utils.newDate("2015-03-01").toString());
});

it("should pre-select Jan of next year on arrowRight", () => {
let preSelected = false;
const setPreSelection = param => {
preSelected = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-12-01"),
day: utils.newDate("2015-12-01"),
setPreSelection: setPreSelection,
preSelection: utils.newDate("2015-12-01")
});

monthComponent.find(".react-datepicker__month-11").simulate('keydown', getKey("ArrowRight"));
expect(preSelected.toString()).to.equal(utils.newDate("2016-01-01").toString());
});

it("should pre-select Dec of previous year on arrowLeft", () => {
let preSelected = false;
const setPreSelection = param => {
preSelected = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-01-01"),
day: utils.newDate("2015-01-01"),
setPreSelection: setPreSelection,
preSelection: utils.newDate("2015-01-01")
});

monthComponent.find(".react-datepicker__month-0").simulate('keydown', getKey("ArrowLeft"));
expect(preSelected.toString()).to.equal(utils.newDate("2014-12-01").toString());
});

it("should prevent navigation to disabled month", () => {
let preSelected = utils.newDate("2015-08-01");
const setPreSelection = param => {
preSelected = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-08-01"),
day: utils.newDate("2015-08-01"),
setPreSelection: setPreSelection,
preSelection: preSelected,
minDate: utils.newDate("2015-03-01"),
maxDate: utils.newDate("2015-08-01")
});

monthComponent.find(".react-datepicker__month-7").simulate('keydown', getKey("ArrowRight"));
expect(preSelected.toString()).to.equal(utils.newDate("2015-08-01").toString());
});

it("should prevent navigation", () => {
let preSelected = utils.newDate("2015-08-01");
const setPreSelection = param => {
preSelected = param;
}

const monthComponent = renderMonth({
selected: utils.newDate("2015-08-01"),
day: utils.newDate("2015-08-01"),
setPreSelection: setPreSelection,
preSelection: preSelected,
disabledKeyboardNavigation: true
});

monthComponent.find(".react-datepicker__month-7").simulate('keydown', getKey("ArrowRight"));
expect(preSelected.toString()).to.equal(utils.newDate("2015-08-01").toString());
});

it("should have label for enabled/disabled month", () => {
const monthComponent = renderMonth({
selected: utils.newDate("2015-03-01"),
day: utils.newDate("2015-03-01"),
setPreSelection: () => {},
preSelection: utils.newDate("2015-03-01"),
minDate: utils.newDate("2015-03-01"),
maxDate: utils.newDate("2015-08-01")
});

const enabled = monthComponent
.find(".react-datepicker__month-4")
.at(0);

const disabled = monthComponent
.find(".react-datepicker__month-0")
.at(0);

expect(enabled.prop('aria-label')).to.equal('Choose May 2015');
expect(disabled.prop('aria-label')).to.equal('Not available January 2015');
});

it("should have custom label for month", () => {
const monthComponent = renderMonth({
selected: utils.newDate("2015-03-01"),
day: utils.newDate("2015-03-01"),
setPreSelection: () => {},
preSelection: utils.newDate("2015-03-01"),
minDate: utils.newDate("2015-03-01"),
maxDate: utils.newDate("2015-08-01"),
ariaLabelPrefix: "Select this",
disabledDayAriaLabelPrefix: "Can't select this",
});

const enabled = monthComponent
.find(".react-datepicker__month-4")
.at(0);

const disabled = monthComponent
.find(".react-datepicker__month-0")
.at(0);

expect(enabled.prop('aria-label')).to.equal('Select this May 2015');
expect(disabled.prop('aria-label')).to.equal(`Can't select this January 2015`);
});
});
});

0 comments on commit 73faa21

Please sign in to comment.