Skip to content

Commit de1e46a

Browse files
Merge pull request #5728 from eelikivi/add-dropdown-keyboard-accessibility
Add dropdown keyboard accessibility
2 parents 09ef10a + 0f2ba5b commit de1e46a

7 files changed

+277
-11
lines changed

src/month_dropdown.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ export default class MonthDropdown extends Component<
5656
visible: boolean,
5757
monthNames: string[],
5858
): React.ReactElement => (
59-
<div
59+
<button
6060
key="read"
61+
type="button"
6162
style={{ visibility: visible ? "visible" : "hidden" }}
6263
className="react-datepicker__month-read-view"
6364
onClick={this.toggleDropdown}
@@ -66,7 +67,7 @@ export default class MonthDropdown extends Component<
6667
<span className="react-datepicker__month-read-view--selected-month">
6768
{monthNames[this.props.month]}
6869
</span>
69-
</div>
70+
</button>
7071
);
7172

7273
renderDropdown = (monthNames: string[]): React.ReactElement => (

src/month_dropdown_options.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,55 @@ interface MonthDropdownOptionsProps {
1010
}
1111

1212
export default class MonthDropdownOptions extends Component<MonthDropdownOptionsProps> {
13+
monthOptionButtonsRef: Record<number, HTMLDivElement | null> = {};
14+
1315
isSelectedMonth = (i: number): boolean => this.props.month === i;
1416

17+
handleOptionKeyDown = (i: number, e: React.KeyboardEvent): void => {
18+
switch (e.key) {
19+
case "Enter":
20+
e.preventDefault();
21+
this.onChange(i);
22+
break;
23+
case "Escape":
24+
e.preventDefault();
25+
this.props.onCancel();
26+
break;
27+
case "ArrowUp":
28+
case "ArrowDown": {
29+
e.preventDefault();
30+
const newMonth =
31+
(i + (e.key === "ArrowUp" ? -1 : 1) + this.props.monthNames.length) %
32+
this.props.monthNames.length;
33+
this.monthOptionButtonsRef[newMonth]?.focus();
34+
break;
35+
}
36+
}
37+
};
38+
1539
renderOptions = (): React.ReactElement[] => {
40+
// Clear refs to prevent memory leaks on re-render
41+
this.monthOptionButtonsRef = {};
42+
1643
return this.props.monthNames.map<React.ReactElement>(
1744
(month: string, i: number): React.ReactElement => (
1845
<div
46+
ref={(el) => {
47+
this.monthOptionButtonsRef[i] = el;
48+
if (this.isSelectedMonth(i)) {
49+
el?.focus();
50+
}
51+
}}
52+
role="button"
53+
tabIndex={0}
1954
className={
2055
this.isSelectedMonth(i)
2156
? "react-datepicker__month-option react-datepicker__month-option--selected_month"
2257
: "react-datepicker__month-option"
2358
}
2459
key={month}
2560
onClick={this.onChange.bind(this, i)}
61+
onKeyDown={this.handleOptionKeyDown.bind(this, i)}
2662
aria-selected={this.isSelectedMonth(i) ? "true" : undefined}
2763
>
2864
{this.isSelectedMonth(i) ? (

src/test/month_dropdown_test.test.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,105 @@ describe("MonthDropdown", () => {
192192
dropdownDateFormat = getMonthDropdown({ locale: "ru" });
193193
expect(dropdownDateFormat.textContent).toContain("декабрь");
194194
});
195+
196+
it("calls the supplied onChange function when a month is selected using arrows and enter key", () => {
197+
const monthReadView = safeQuerySelector(
198+
monthDropdown,
199+
".react-datepicker__month-read-view",
200+
);
201+
fireEvent.click(monthReadView);
202+
203+
const monthOptions = safeQuerySelectorAll(
204+
monthDropdown,
205+
".react-datepicker__month-option",
206+
);
207+
208+
const monthOption = monthOptions[3]!;
209+
fireEvent.keyDown(monthOption, { key: "ArrowDown" });
210+
211+
const nextMonthOption = monthOptions[4];
212+
expect(document.activeElement).toEqual(nextMonthOption);
213+
214+
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
215+
expect(handleChangeResult).toEqual(4);
216+
});
217+
218+
it("handles ArrowUp key navigation correctly", () => {
219+
const monthReadView = safeQuerySelector(
220+
monthDropdown,
221+
".react-datepicker__month-read-view",
222+
);
223+
fireEvent.click(monthReadView);
224+
225+
const monthOptions = safeQuerySelectorAll(
226+
monthDropdown,
227+
".react-datepicker__month-option",
228+
);
229+
230+
const monthOption = monthOptions[5]!;
231+
fireEvent.keyDown(monthOption, { key: "ArrowUp" });
232+
233+
const prevMonthOption = monthOptions[4];
234+
expect(document.activeElement).toEqual(prevMonthOption);
235+
});
236+
237+
it("handles Escape key to cancel dropdown", () => {
238+
const monthReadView = safeQuerySelector(
239+
monthDropdown,
240+
".react-datepicker__month-read-view",
241+
);
242+
fireEvent.click(monthReadView);
243+
244+
const monthOptions = safeQuerySelectorAll(
245+
monthDropdown,
246+
".react-datepicker__month-option",
247+
);
248+
249+
const monthOption = monthOptions[5]!;
250+
fireEvent.keyDown(monthOption, { key: "Escape" });
251+
252+
expect(
253+
monthDropdown?.querySelectorAll(".react-datepicker__month-dropdown"),
254+
).toHaveLength(0);
255+
});
256+
257+
it("wraps around when using ArrowUp on first month", () => {
258+
const monthReadView = safeQuerySelector(
259+
monthDropdown,
260+
".react-datepicker__month-read-view",
261+
);
262+
fireEvent.click(monthReadView);
263+
264+
const monthOptions = safeQuerySelectorAll(
265+
monthDropdown,
266+
".react-datepicker__month-option",
267+
);
268+
269+
const firstMonthOption = monthOptions[0]!;
270+
fireEvent.keyDown(firstMonthOption, { key: "ArrowUp" });
271+
272+
const lastMonthOption = monthOptions[11];
273+
expect(document.activeElement).toEqual(lastMonthOption);
274+
});
275+
276+
it("wraps around when using ArrowDown on last month", () => {
277+
const monthReadView = safeQuerySelector(
278+
monthDropdown,
279+
".react-datepicker__month-read-view",
280+
);
281+
fireEvent.click(monthReadView);
282+
283+
const monthOptions = safeQuerySelectorAll(
284+
monthDropdown,
285+
".react-datepicker__month-option",
286+
);
287+
288+
const lastMonthOption = monthOptions[11]!;
289+
fireEvent.keyDown(lastMonthOption, { key: "ArrowDown" });
290+
291+
const firstMonthOption = monthOptions[0];
292+
expect(document.activeElement).toEqual(firstMonthOption);
293+
});
195294
});
196295

197296
describe("select mode", () => {

src/test/year_dropdown_options_test.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,76 @@ describe("YearDropdownOptions", () => {
146146
expect(onCancelSpy).toHaveBeenCalledTimes(2);
147147
});
148148

149+
it("handles Enter key to select year", () => {
150+
const yearOptions = safeQuerySelectorAll(
151+
yearDropdown,
152+
".react-datepicker__year-option",
153+
);
154+
const year2014Option = yearOptions.find((node) =>
155+
node.textContent?.includes("2014"),
156+
);
157+
158+
if (!year2014Option) {
159+
throw new Error("Year 2014 not found!");
160+
}
161+
162+
fireEvent.keyDown(year2014Option, { key: "Enter" });
163+
expect(handleChangeResult).toBe(2014);
164+
});
165+
166+
it("handles Escape key to cancel dropdown", () => {
167+
const yearOptions = safeQuerySelectorAll(
168+
yearDropdown,
169+
".react-datepicker__year-option",
170+
);
171+
const year2014Option = yearOptions.find((node) =>
172+
node.textContent?.includes("2014"),
173+
);
174+
175+
if (!year2014Option) {
176+
throw new Error("Year 2014 not found!");
177+
}
178+
179+
fireEvent.keyDown(year2014Option, { key: "Escape" });
180+
expect(onCancelSpy).toHaveBeenCalled();
181+
});
182+
183+
it("handles ArrowUp key navigation", () => {
184+
const yearOptions = safeQuerySelectorAll(
185+
yearDropdown,
186+
".react-datepicker__year-option",
187+
);
188+
const year2015Option = yearOptions.find((node) =>
189+
node.textContent?.includes("✓2015"),
190+
);
191+
192+
if (!year2015Option) {
193+
throw new Error("Year 2015 not found!");
194+
}
195+
196+
fireEvent.keyDown(year2015Option, { key: "ArrowUp" });
197+
// ArrowUp should focus year 2016 (year + 1 in the code)
198+
expect(document.activeElement?.textContent).toContain("2016");
199+
});
200+
201+
it("handles ArrowDown key navigation", () => {
202+
const yearOptions = safeQuerySelectorAll(
203+
yearDropdown,
204+
".react-datepicker__year-option",
205+
);
206+
const year2015Option = yearOptions.find((node) =>
207+
node.textContent?.includes("✓2015"),
208+
);
209+
210+
if (!year2015Option) {
211+
throw new Error("Year 2015 not found!");
212+
}
213+
214+
fireEvent.keyDown(year2015Option, { key: "ArrowDown" });
215+
// ArrowDown should focus year 2014 (year - 1 in the code)
216+
expect(document.activeElement?.textContent).toContain("2014");
217+
});
218+
149219
describe("selected", () => {
150220
const className = "react-datepicker__year-option--selected_year";
151221
let yearOptions: HTMLElement[];

src/test/year_dropdown_test.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,28 @@ describe("YearDropdown", () => {
116116
fireEvent.click(yearOption);
117117
expect(lastOnChangeValue).toEqual(2014);
118118
});
119+
120+
it("calls the supplied onChange function when a year is selected using arrows and enter key", () => {
121+
const yearReadView = safeQuerySelector(
122+
yearDropdown,
123+
".react-datepicker__year-read-view",
124+
);
125+
fireEvent.click(yearReadView);
126+
const minYearOptionsLen = 7;
127+
const yearOptions = safeQuerySelectorAll(
128+
yearDropdown,
129+
".react-datepicker__year-option",
130+
minYearOptionsLen,
131+
);
132+
const yearOption = yearOptions[6]!;
133+
fireEvent.keyDown(yearOption, { key: "ArrowUp" });
134+
135+
const previousYearOption = yearOptions[5]!;
136+
expect(document.activeElement).toBe(previousYearOption);
137+
138+
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
139+
expect(lastOnChangeValue).toEqual(2016);
140+
});
119141
});
120142

121143
describe("select mode", () => {

src/year_dropdown.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface YearDropdownProps
1212
dropdownMode: "scroll" | "select";
1313
onChange: (year: number) => void;
1414
date: Date;
15-
onSelect?: (date: Date, event?: React.MouseEvent<HTMLDivElement>) => void;
15+
onSelect?: (date: Date, event?: React.MouseEvent<HTMLButtonElement>) => void;
1616
setOpen?: (open: boolean) => void;
1717
}
1818

@@ -62,19 +62,18 @@ export default class YearDropdown extends Component<
6262
);
6363

6464
renderReadView = (visible: boolean): React.ReactElement => (
65-
<div
65+
<button
6666
key="read"
67+
type="button"
6768
style={{ visibility: visible ? "visible" : "hidden" }}
6869
className="react-datepicker__year-read-view"
69-
onClick={(event: React.MouseEvent<HTMLDivElement>): void =>
70-
this.toggleDropdown(event)
71-
}
70+
onClick={this.toggleDropdown}
7271
>
7372
<span className="react-datepicker__year-read-view--down-arrow" />
7473
<span className="react-datepicker__year-read-view--selected-year">
7574
{this.props.year}
7675
</span>
77-
</div>
76+
</button>
7877
);
7978

8079
renderDropdown = (): React.ReactElement => (
@@ -101,7 +100,7 @@ export default class YearDropdown extends Component<
101100
this.props.onChange(year);
102101
};
103102

104-
toggleDropdown = (event?: React.MouseEvent<HTMLDivElement>): void => {
103+
toggleDropdown = (event?: React.MouseEvent<HTMLButtonElement>): void => {
105104
this.setState(
106105
{
107106
dropdownVisible: !this.state.dropdownVisible,
@@ -116,13 +115,16 @@ export default class YearDropdown extends Component<
116115

117116
handleYearChange = (
118117
date: Date,
119-
event?: React.MouseEvent<HTMLDivElement>,
118+
event?: React.MouseEvent<HTMLButtonElement>,
120119
): void => {
121120
this.onSelect?.(date, event);
122121
this.setOpen();
123122
};
124123

125-
onSelect = (date: Date, event?: React.MouseEvent<HTMLDivElement>): void => {
124+
onSelect = (
125+
date: Date,
126+
event?: React.MouseEvent<HTMLButtonElement>,
127+
): void => {
126128
this.props.onSelect?.(date, event);
127129
};
128130

0 commit comments

Comments
 (0)