Skip to content

Add dropdown keyboard accessibility #5728

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/month_dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ export default class MonthDropdown extends Component<
visible: boolean,
monthNames: string[],
): React.ReactElement => (
<div
<button
key="read"
type="button"
style={{ visibility: visible ? "visible" : "hidden" }}
className="react-datepicker__month-read-view"
onClick={this.toggleDropdown}
Expand All @@ -66,7 +67,7 @@ export default class MonthDropdown extends Component<
<span className="react-datepicker__month-read-view--selected-month">
{monthNames[this.props.month]}
</span>
</div>
</button>
);

renderDropdown = (monthNames: string[]): React.ReactElement => (
Expand Down
33 changes: 33 additions & 0 deletions src/month_dropdown_options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,52 @@ interface MonthDropdownOptionsProps {
}

export default class MonthDropdownOptions extends Component<MonthDropdownOptionsProps> {
monthOptionButtonsRef: (HTMLDivElement | null)[] = [];

isSelectedMonth = (i: number): boolean => this.props.month === i;

handleOptionKeyDown = (i: number, e: React.KeyboardEvent): void => {
switch (e.key) {
case "Enter":
e.preventDefault();
this.onChange(i);
break;
case "Escape":
e.preventDefault();
this.props.onCancel();
break;
case "ArrowUp":
case "ArrowDown": {
e.preventDefault();
const newMonth =
(i + (e.key === "ArrowUp" ? -1 : 1) + this.props.monthNames.length) %
this.props.monthNames.length;
this.monthOptionButtonsRef[newMonth]?.focus();
break;
}
}
};

renderOptions = (): React.ReactElement[] => {
return this.props.monthNames.map<React.ReactElement>(
(month: string, i: number): React.ReactElement => (
<div
ref={(el) => {
this.monthOptionButtonsRef?.push(el);
if (this.isSelectedMonth(i)) {
el?.focus();
}
}}
role="button"
tabIndex={0}
className={
this.isSelectedMonth(i)
? "react-datepicker__month-option react-datepicker__month-option--selected_month"
: "react-datepicker__month-option"
}
key={month}
onClick={this.onChange.bind(this, i)}
onKeyDown={this.handleOptionKeyDown.bind(this, i)}
aria-selected={this.isSelectedMonth(i) ? "true" : undefined}
>
{this.isSelectedMonth(i) ? (
Expand Down
22 changes: 22 additions & 0 deletions src/test/month_dropdown_test.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ describe("MonthDropdown", () => {
dropdownDateFormat = getMonthDropdown({ locale: "ru" });
expect(dropdownDateFormat.textContent).toContain("декабрь");
});

it("calls the supplied onChange function when a month is selected using arrows and enter key", () => {
const monthReadView = safeQuerySelector(
monthDropdown,
".react-datepicker__month-read-view",
);
fireEvent.click(monthReadView);

const monthOptions = safeQuerySelectorAll(
monthDropdown,
".react-datepicker__month-option",
);

const monthOption = monthOptions[3]!;
fireEvent.keyDown(monthOption, { key: "ArrowDown" });

const nextMonthOption = monthOptions[4];
expect(document.activeElement).toEqual(nextMonthOption);

fireEvent.keyDown(document.activeElement!, { key: "Enter" });
expect(handleChangeResult).toEqual(4);
})
});

describe("select mode", () => {
Expand Down
22 changes: 22 additions & 0 deletions src/test/year_dropdown_test.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,28 @@ describe("YearDropdown", () => {
fireEvent.click(yearOption);
expect(lastOnChangeValue).toEqual(2014);
});

it("calls the supplied onChange function when a year is selected using arrows and enter key", () => {
const yearReadView = safeQuerySelector(
yearDropdown,
".react-datepicker__year-read-view",
);
fireEvent.click(yearReadView);
const minYearOptionsLen = 7;
const yearOptions = safeQuerySelectorAll(
yearDropdown,
".react-datepicker__year-option",
minYearOptionsLen,
);
const yearOption = yearOptions[6]!;
fireEvent.keyDown(yearOption, { key: "ArrowUp" });

const previousYearOption = yearOptions[5]!;
expect(document.activeElement).toBe(previousYearOption);

fireEvent.keyDown(document.activeElement!, { key: "Enter" });
expect(lastOnChangeValue).toEqual(2016);
});
});

describe("select mode", () => {
Expand Down
17 changes: 8 additions & 9 deletions src/year_dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface YearDropdownProps
dropdownMode: "scroll" | "select";
onChange: (year: number) => void;
date: Date;
onSelect?: (date: Date, event?: React.MouseEvent<HTMLDivElement>) => void;
onSelect?: (date: Date, event?: React.MouseEvent<HTMLButtonElement>) => void;
setOpen?: (open: boolean) => void;
}

Expand Down Expand Up @@ -62,19 +62,18 @@ export default class YearDropdown extends Component<
);

renderReadView = (visible: boolean): React.ReactElement => (
<div
<button
key="read"
type="button"
style={{ visibility: visible ? "visible" : "hidden" }}
className="react-datepicker__year-read-view"
onClick={(event: React.MouseEvent<HTMLDivElement>): void =>
this.toggleDropdown(event)
}
onClick={this.toggleDropdown}
>
<span className="react-datepicker__year-read-view--down-arrow" />
<span className="react-datepicker__year-read-view--selected-year">
{this.props.year}
</span>
</div>
</button>
);

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

toggleDropdown = (event?: React.MouseEvent<HTMLDivElement>): void => {
toggleDropdown = (event?: React.MouseEvent<HTMLButtonElement>): void => {
this.setState(
{
dropdownVisible: !this.state.dropdownVisible,
Expand All @@ -116,13 +115,13 @@ export default class YearDropdown extends Component<

handleYearChange = (
date: Date,
event?: React.MouseEvent<HTMLDivElement>,
event?: React.MouseEvent<HTMLButtonElement>,
): void => {
this.onSelect?.(date, event);
this.setOpen();
};

onSelect = (date: Date, event?: React.MouseEvent<HTMLDivElement>): void => {
onSelect = (date: Date, event?: React.MouseEvent<HTMLButtonElement>): void => {
this.props.onSelect?.(date, event);
};

Expand Down
30 changes: 30 additions & 0 deletions src/year_dropdown_options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,48 @@ export default class YearDropdownOptions extends Component<
}

dropdownRef: React.RefObject<HTMLDivElement | null>;
yearOptionButtonsRef: Record<number, HTMLDivElement | null> = {};

handleOptionKeyDown = (year: number, e: React.KeyboardEvent): void => {
switch (e.key) {
case "Enter":
e.preventDefault();
this.onChange(year);
break;
case "Escape":
e.preventDefault();
this.props.onCancel();
break;
case "ArrowUp":
case "ArrowDown": {
e.preventDefault();
const newYear = year + (e.key === "ArrowUp" ? 1 : -1);
this.yearOptionButtonsRef[newYear]?.focus();
break;
}
}
};

renderOptions = (): React.ReactElement[] => {
const selectedYear = this.props.year;
const options = this.state.yearsList.map((year) => (
<div
ref={(el) => {
this.yearOptionButtonsRef[year] = el;
if (year === selectedYear) {
el?.focus();
}
}}
role="button"
tabIndex={0}
className={
selectedYear === year
? "react-datepicker__year-option react-datepicker__year-option--selected_year"
: "react-datepicker__year-option"
}
key={year}
onClick={this.onChange.bind(this, year)}
onKeyDown={this.handleOptionKeyDown.bind(this, year)}
aria-selected={selectedYear === year ? "true" : undefined}
>
{selectedYear === year ? (
Expand Down