Skip to content

Commit 73faa21

Browse files
Adds keyboard navigation for month picker (Hacker0x01#2389)
* 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]>
1 parent dac2214 commit 73faa21

File tree

4 files changed

+294
-1
lines changed

4 files changed

+294
-1
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ The examples are hosted within the docs folder and are ran in the simple app tha
163163
- _End_: Move to the next year.
164164
- _Enter/Esc/Tab_: close the calendar. (Enter & Esc calls preventDefault)
165165

166+
#### For month picker
167+
168+
- _Left_: Move to the previous month.
169+
- _Right_: Move to the next month.
170+
- _Enter_: Select date and close the calendar
171+
166172
## License
167173

168174
Copyright (c) 2019 HackerOne Inc. and individual contributors. Licensed under MIT license, see [LICENSE](LICENSE) for the full license.

src/calendar.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,7 @@ export default class Calendar extends React.Component {
794794
fixedHeight={this.props.fixedHeight}
795795
filterDate={this.props.filterDate}
796796
preSelection={this.props.preSelection}
797+
setPreSelection={this.props.setPreSelection}
797798
selected={this.props.selected}
798799
selectsStart={this.props.selectsStart}
799800
selectsEnd={this.props.selectsEnd}

src/month.jsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default class Month extends React.Component {
3434
onWeekSelect: PropTypes.func,
3535
peekNextMonth: PropTypes.bool,
3636
preSelection: PropTypes.instanceOf(Date),
37+
setPreSelection: PropTypes.func,
3738
selected: PropTypes.instanceOf(Date),
3839
selectingDate: PropTypes.instanceOf(Date),
3940
selectsEnd: PropTypes.bool,
@@ -57,6 +58,12 @@ export default class Month extends React.Component {
5758
])
5859
};
5960

61+
MONTH_REFS = Array(12).fill().map(() => React.createRef());
62+
63+
isDisabled = date => utils.isDayDisabled(date, this.props);
64+
65+
isExcluded = date => utils.isDayExcluded(date, this.props);
66+
6067
handleDayClick = (day, event) => {
6168
if (this.props.onDayClick) {
6269
this.props.onDayClick(day, event, this.props.orderInDisplay);
@@ -196,6 +203,30 @@ export default class Month extends React.Component {
196203
);
197204
};
198205

206+
handleMonthNavigation = (newMonth, newDate) => {
207+
if(this.isDisabled(newDate) || this.isExcluded(newDate)) return;
208+
this.props.setPreSelection(newDate);
209+
this.MONTH_REFS[newMonth].current && this.MONTH_REFS[newMonth].current.focus();
210+
}
211+
212+
onMonthKeyDown = (event, month) => {
213+
const eventKey = event.key;
214+
if (!this.props.disabledKeyboardNavigation) {
215+
switch (eventKey) {
216+
case "Enter":
217+
this.onMonthClick(event, month);
218+
this.props.setPreSelection(this.props.selected);
219+
break;
220+
case "ArrowRight":
221+
this.handleMonthNavigation(month === 11 ? 0 : month+1, utils.addMonths(this.props.preSelection, 1));
222+
break;
223+
case "ArrowLeft":
224+
this.handleMonthNavigation(month === 0 ? 11 : month-1, utils.subMonths(this.props.preSelection, 1));
225+
break;
226+
}
227+
}
228+
};
229+
199230
onQuarterClick = (e, q) => {
200231
this.handleDayClick(
201232
utils.getStartOfQuarter(utils.setQuarter(this.props.day, q)),
@@ -204,7 +235,7 @@ export default class Month extends React.Component {
204235
};
205236

206237
getMonthClassNames = m => {
207-
const { day, startDate, endDate, selected, minDate, maxDate } = this.props;
238+
const { day, startDate, endDate, selected, minDate, maxDate, preSelection } = this.props;
208239
return classnames(
209240
"react-datepicker__month-text",
210241
`react-datepicker__month-${m}`,
@@ -215,6 +246,7 @@ export default class Month extends React.Component {
215246
"react-datepicker__month--selected":
216247
utils.getMonth(day) === m &&
217248
utils.getYear(day) === utils.getYear(selected),
249+
"react-datepicker__month-text--keyboard-selected": utils.getMonth(preSelection) === m,
218250
"react-datepicker__month--in-range": utils.isMonthinRange(
219251
startDate,
220252
endDate,
@@ -227,6 +259,32 @@ export default class Month extends React.Component {
227259
);
228260
};
229261

262+
getTabIndex = (m) => {
263+
const preSelectedMonth = utils.getMonth(this.props.preSelection);
264+
const tabIndex =
265+
!this.props.disabledKeyboardNavigation && m === preSelectedMonth
266+
? "0"
267+
: "-1";
268+
269+
return tabIndex;
270+
};
271+
272+
getAriaLabel = month => {
273+
const {
274+
ariaLabelPrefix = "Choose",
275+
disabledDayAriaLabelPrefix = "Not available",
276+
day
277+
} = this.props;
278+
279+
const labelDate = utils.setMonth(day, month)
280+
const prefix =
281+
this.isDisabled(labelDate) || this.isExcluded(labelDate)
282+
? disabledDayAriaLabelPrefix
283+
: ariaLabelPrefix;
284+
285+
return `${prefix} ${utils.formatDate(labelDate, "MMMM yyyy")}`;
286+
};
287+
230288
getQuarterClassNames = q => {
231289
const { day, startDate, endDate, selected, minDate, maxDate } = this.props;
232290
return classnames(
@@ -278,11 +336,18 @@ export default class Month extends React.Component {
278336
<div className="react-datepicker__month-wrapper" key={i}>
279337
{month.map((m, j) => (
280338
<div
339+
ref={this.MONTH_REFS[m]}
281340
key={j}
282341
onClick={ev => {
283342
this.onMonthClick(ev, m);
284343
}}
344+
onKeyDown={ev => {
345+
this.onMonthKeyDown(ev, m);
346+
}}
347+
tabIndex={this.getTabIndex(m)}
285348
className={this.getMonthClassNames(m)}
349+
role="button"
350+
aria-label={this.getAriaLabel(m)}
286351
>
287352
{showFullMonthYearPicker
288353
? utils.getMonthInLocale(m, locale)

test/month_test.js

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import React from "react";
2+
import ReactDOM from "react-dom";
23
import Month from "../src/month";
34
import Day from "../src/day";
45
import range from "lodash/range";
56
import { mount, shallow } from "enzyme";
67
import * as utils from "../src/date_utils";
78
import TestUtils from "react-dom/test-utils";
89

10+
function getKey(key) {
11+
switch (key) {
12+
case "Tab":
13+
return { key, code: 9, which: 9 };
14+
case "Enter":
15+
return { key, code: 13, which: 13 };
16+
case "ArrowLeft":
17+
return { key, code: 37, which: 37 };
18+
case "ArrowRight":
19+
return { key, code: 39, which: 39 };
20+
}
21+
throw new Error("Unknown key :" + key);
22+
}
23+
24+
925
describe("Month", () => {
1026
function assertDateRangeInclusive(month, start, end) {
1127
const dayCount = utils.getDaysDiff(end, start) + 1;
@@ -291,4 +307,209 @@ describe("Month", () => {
291307
true
292308
);
293309
});
310+
311+
it("should render full month name", () => {
312+
const monthComponent = mount(
313+
<Month
314+
day={utils.newDate("2015-12-01")}
315+
showMonthYearPicker
316+
showFullMonthYearPicker
317+
/>
318+
);
319+
const month = monthComponent.find(".react-datepicker__month-1").at(0);
320+
321+
expect(month.text()).to.equal('February');
322+
});
323+
324+
it("should render short month name", () => {
325+
const monthComponent = mount(
326+
<Month
327+
day={utils.newDate("2015-12-01")}
328+
showMonthYearPicker
329+
/>
330+
);
331+
const month = monthComponent.find(".react-datepicker__month-1").at(0);
332+
333+
expect(month.text()).to.equal('Feb');
334+
});
335+
336+
describe("Keyboard navigation", () => {
337+
const renderMonth = (props) => shallow(<Month showMonthYearPicker {...props} />);
338+
339+
it("should trigger setPreSelection and set March as pre-selected on arrowRight", () => {
340+
let preSelected = false;
341+
const setPreSelection = param => {
342+
preSelected = param;
343+
}
344+
345+
const monthComponent = renderMonth({
346+
selected: utils.newDate("2015-02-01"),
347+
day: utils.newDate("2015-02-01"),
348+
setPreSelection: setPreSelection,
349+
preSelection: utils.newDate("2015-02-01"),
350+
});
351+
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("Tab"));
352+
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowRight"));
353+
354+
expect(preSelected.toString()).to.equal(utils.newDate("2015-03-01").toString());
355+
});
356+
357+
it("should trigger setPreSelection and set January as pre-selected on arrowLeft", () => {
358+
let preSelected = false;
359+
const setPreSelection = param => {
360+
preSelected = param;
361+
}
362+
const monthComponent = renderMonth({
363+
selected: utils.newDate("2015-02-01"),
364+
day: utils.newDate("2015-02-01"),
365+
setPreSelection: setPreSelection,
366+
preSelection: utils.newDate("2015-02-01"),
367+
});
368+
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowLeft"));
369+
370+
expect(preSelected.toString()).to.equal(utils.newDate("2015-01-01").toString());
371+
});
372+
373+
it("should select March when Enter is pressed", () => {
374+
let preSelected = false;
375+
let selectedDate = null;
376+
const setPreSelection = () => {
377+
preSelected = true;
378+
}
379+
const setSelectedDate = param => {
380+
selectedDate = param;
381+
}
382+
383+
const monthComponent = renderMonth({
384+
selected: utils.newDate("2015-02-01"),
385+
day: utils.newDate("2015-02-01"),
386+
setPreSelection: setPreSelection,
387+
preSelection: utils.newDate("2015-02-01"),
388+
onDayClick: setSelectedDate
389+
});
390+
391+
monthComponent.find(".react-datepicker__month-1").simulate('keydown', getKey("ArrowLeft"));
392+
monthComponent.find(".react-datepicker__month-2").simulate('keydown', getKey("Enter"));
393+
394+
expect(preSelected).to.equal(true);
395+
expect(selectedDate.toString()).to.equal(utils.newDate("2015-03-01").toString());
396+
});
397+
398+
it("should pre-select Jan of next year on arrowRight", () => {
399+
let preSelected = false;
400+
const setPreSelection = param => {
401+
preSelected = param;
402+
}
403+
404+
const monthComponent = renderMonth({
405+
selected: utils.newDate("2015-12-01"),
406+
day: utils.newDate("2015-12-01"),
407+
setPreSelection: setPreSelection,
408+
preSelection: utils.newDate("2015-12-01")
409+
});
410+
411+
monthComponent.find(".react-datepicker__month-11").simulate('keydown', getKey("ArrowRight"));
412+
expect(preSelected.toString()).to.equal(utils.newDate("2016-01-01").toString());
413+
});
414+
415+
it("should pre-select Dec of previous year on arrowLeft", () => {
416+
let preSelected = false;
417+
const setPreSelection = param => {
418+
preSelected = param;
419+
}
420+
421+
const monthComponent = renderMonth({
422+
selected: utils.newDate("2015-01-01"),
423+
day: utils.newDate("2015-01-01"),
424+
setPreSelection: setPreSelection,
425+
preSelection: utils.newDate("2015-01-01")
426+
});
427+
428+
monthComponent.find(".react-datepicker__month-0").simulate('keydown', getKey("ArrowLeft"));
429+
expect(preSelected.toString()).to.equal(utils.newDate("2014-12-01").toString());
430+
});
431+
432+
it("should prevent navigation to disabled month", () => {
433+
let preSelected = utils.newDate("2015-08-01");
434+
const setPreSelection = param => {
435+
preSelected = param;
436+
}
437+
438+
const monthComponent = renderMonth({
439+
selected: utils.newDate("2015-08-01"),
440+
day: utils.newDate("2015-08-01"),
441+
setPreSelection: setPreSelection,
442+
preSelection: preSelected,
443+
minDate: utils.newDate("2015-03-01"),
444+
maxDate: utils.newDate("2015-08-01")
445+
});
446+
447+
monthComponent.find(".react-datepicker__month-7").simulate('keydown', getKey("ArrowRight"));
448+
expect(preSelected.toString()).to.equal(utils.newDate("2015-08-01").toString());
449+
});
450+
451+
it("should prevent navigation", () => {
452+
let preSelected = utils.newDate("2015-08-01");
453+
const setPreSelection = param => {
454+
preSelected = param;
455+
}
456+
457+
const monthComponent = renderMonth({
458+
selected: utils.newDate("2015-08-01"),
459+
day: utils.newDate("2015-08-01"),
460+
setPreSelection: setPreSelection,
461+
preSelection: preSelected,
462+
disabledKeyboardNavigation: true
463+
});
464+
465+
monthComponent.find(".react-datepicker__month-7").simulate('keydown', getKey("ArrowRight"));
466+
expect(preSelected.toString()).to.equal(utils.newDate("2015-08-01").toString());
467+
});
468+
469+
it("should have label for enabled/disabled month", () => {
470+
const monthComponent = renderMonth({
471+
selected: utils.newDate("2015-03-01"),
472+
day: utils.newDate("2015-03-01"),
473+
setPreSelection: () => {},
474+
preSelection: utils.newDate("2015-03-01"),
475+
minDate: utils.newDate("2015-03-01"),
476+
maxDate: utils.newDate("2015-08-01")
477+
});
478+
479+
const enabled = monthComponent
480+
.find(".react-datepicker__month-4")
481+
.at(0);
482+
483+
const disabled = monthComponent
484+
.find(".react-datepicker__month-0")
485+
.at(0);
486+
487+
expect(enabled.prop('aria-label')).to.equal('Choose May 2015');
488+
expect(disabled.prop('aria-label')).to.equal('Not available January 2015');
489+
});
490+
491+
it("should have custom label for month", () => {
492+
const monthComponent = renderMonth({
493+
selected: utils.newDate("2015-03-01"),
494+
day: utils.newDate("2015-03-01"),
495+
setPreSelection: () => {},
496+
preSelection: utils.newDate("2015-03-01"),
497+
minDate: utils.newDate("2015-03-01"),
498+
maxDate: utils.newDate("2015-08-01"),
499+
ariaLabelPrefix: "Select this",
500+
disabledDayAriaLabelPrefix: "Can't select this",
501+
});
502+
503+
const enabled = monthComponent
504+
.find(".react-datepicker__month-4")
505+
.at(0);
506+
507+
const disabled = monthComponent
508+
.find(".react-datepicker__month-0")
509+
.at(0);
510+
511+
expect(enabled.prop('aria-label')).to.equal('Select this May 2015');
512+
expect(disabled.prop('aria-label')).to.equal(`Can't select this January 2015`);
513+
});
514+
});
294515
});

0 commit comments

Comments
 (0)