diff --git a/src/month_dropdown.tsx b/src/month_dropdown.tsx index fa5afcd22e..a1ed659efd 100644 --- a/src/month_dropdown.tsx +++ b/src/month_dropdown.tsx @@ -56,8 +56,9 @@ export default class MonthDropdown extends Component< visible: boolean, monthNames: string[], ): React.ReactElement => ( -
{monthNames[this.props.month]} -
+ ); renderDropdown = (monthNames: string[]): React.ReactElement => ( diff --git a/src/month_dropdown_options.tsx b/src/month_dropdown_options.tsx index 17af165ab3..e9d2cf12cb 100644 --- a/src/month_dropdown_options.tsx +++ b/src/month_dropdown_options.tsx @@ -10,12 +10,47 @@ interface MonthDropdownOptionsProps { } export default class MonthDropdownOptions extends Component { + monthOptionButtonsRef: Record = {}; + 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[] => { + // Clear refs to prevent memory leaks on re-render + this.monthOptionButtonsRef = {}; + return this.props.monthNames.map( (month: string, i: number): React.ReactElement => (
{ + this.monthOptionButtonsRef[i] = 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" @@ -23,6 +58,7 @@ export default class MonthDropdownOptions extends Component {this.isSelectedMonth(i) ? ( diff --git a/src/test/month_dropdown_test.test.tsx b/src/test/month_dropdown_test.test.tsx index ac3d918c1c..0fb5f4abad 100644 --- a/src/test/month_dropdown_test.test.tsx +++ b/src/test/month_dropdown_test.test.tsx @@ -192,6 +192,105 @@ 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); + }); + + it("handles ArrowUp key navigation correctly", () => { + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + const monthOption = monthOptions[5]!; + fireEvent.keyDown(monthOption, { key: "ArrowUp" }); + + const prevMonthOption = monthOptions[4]; + expect(document.activeElement).toEqual(prevMonthOption); + }); + + it("handles Escape key to cancel dropdown", () => { + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + const monthOption = monthOptions[5]!; + fireEvent.keyDown(monthOption, { key: "Escape" }); + + expect( + monthDropdown?.querySelectorAll(".react-datepicker__month-dropdown"), + ).toHaveLength(0); + }); + + it("wraps around when using ArrowUp on first month", () => { + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + const firstMonthOption = monthOptions[0]!; + fireEvent.keyDown(firstMonthOption, { key: "ArrowUp" }); + + const lastMonthOption = monthOptions[11]; + expect(document.activeElement).toEqual(lastMonthOption); + }); + + it("wraps around when using ArrowDown on last month", () => { + const monthReadView = safeQuerySelector( + monthDropdown, + ".react-datepicker__month-read-view", + ); + fireEvent.click(monthReadView); + + const monthOptions = safeQuerySelectorAll( + monthDropdown, + ".react-datepicker__month-option", + ); + + const lastMonthOption = monthOptions[11]!; + fireEvent.keyDown(lastMonthOption, { key: "ArrowDown" }); + + const firstMonthOption = monthOptions[0]; + expect(document.activeElement).toEqual(firstMonthOption); + }); }); describe("select mode", () => { diff --git a/src/test/year_dropdown_options_test.test.tsx b/src/test/year_dropdown_options_test.test.tsx index 370124cddd..59cddd894f 100644 --- a/src/test/year_dropdown_options_test.test.tsx +++ b/src/test/year_dropdown_options_test.test.tsx @@ -146,6 +146,76 @@ describe("YearDropdownOptions", () => { expect(onCancelSpy).toHaveBeenCalledTimes(2); }); + it("handles Enter key to select year", () => { + const yearOptions = safeQuerySelectorAll( + yearDropdown, + ".react-datepicker__year-option", + ); + const year2014Option = yearOptions.find((node) => + node.textContent?.includes("2014"), + ); + + if (!year2014Option) { + throw new Error("Year 2014 not found!"); + } + + fireEvent.keyDown(year2014Option, { key: "Enter" }); + expect(handleChangeResult).toBe(2014); + }); + + it("handles Escape key to cancel dropdown", () => { + const yearOptions = safeQuerySelectorAll( + yearDropdown, + ".react-datepicker__year-option", + ); + const year2014Option = yearOptions.find((node) => + node.textContent?.includes("2014"), + ); + + if (!year2014Option) { + throw new Error("Year 2014 not found!"); + } + + fireEvent.keyDown(year2014Option, { key: "Escape" }); + expect(onCancelSpy).toHaveBeenCalled(); + }); + + it("handles ArrowUp key navigation", () => { + const yearOptions = safeQuerySelectorAll( + yearDropdown, + ".react-datepicker__year-option", + ); + const year2015Option = yearOptions.find((node) => + node.textContent?.includes("✓2015"), + ); + + if (!year2015Option) { + throw new Error("Year 2015 not found!"); + } + + fireEvent.keyDown(year2015Option, { key: "ArrowUp" }); + // ArrowUp should focus year 2016 (year + 1 in the code) + expect(document.activeElement?.textContent).toContain("2016"); + }); + + it("handles ArrowDown key navigation", () => { + const yearOptions = safeQuerySelectorAll( + yearDropdown, + ".react-datepicker__year-option", + ); + const year2015Option = yearOptions.find((node) => + node.textContent?.includes("✓2015"), + ); + + if (!year2015Option) { + throw new Error("Year 2015 not found!"); + } + + fireEvent.keyDown(year2015Option, { key: "ArrowDown" }); + // ArrowDown should focus year 2014 (year - 1 in the code) + expect(document.activeElement?.textContent).toContain("2014"); + }); + describe("selected", () => { const className = "react-datepicker__year-option--selected_year"; let yearOptions: HTMLElement[]; diff --git a/src/test/year_dropdown_test.test.tsx b/src/test/year_dropdown_test.test.tsx index 784a7ac21f..83a3db63ac 100644 --- a/src/test/year_dropdown_test.test.tsx +++ b/src/test/year_dropdown_test.test.tsx @@ -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", () => { diff --git a/src/year_dropdown.tsx b/src/year_dropdown.tsx index ac5e3da6bf..28678c0fad 100644 --- a/src/year_dropdown.tsx +++ b/src/year_dropdown.tsx @@ -12,7 +12,7 @@ interface YearDropdownProps dropdownMode: "scroll" | "select"; onChange: (year: number) => void; date: Date; - onSelect?: (date: Date, event?: React.MouseEvent) => void; + onSelect?: (date: Date, event?: React.MouseEvent) => void; setOpen?: (open: boolean) => void; } @@ -62,19 +62,18 @@ export default class YearDropdown extends Component< ); renderReadView = (visible: boolean): React.ReactElement => ( -
): void => - this.toggleDropdown(event) - } + onClick={this.toggleDropdown} > {this.props.year} -
+ ); renderDropdown = (): React.ReactElement => ( @@ -101,7 +100,7 @@ export default class YearDropdown extends Component< this.props.onChange(year); }; - toggleDropdown = (event?: React.MouseEvent): void => { + toggleDropdown = (event?: React.MouseEvent): void => { this.setState( { dropdownVisible: !this.state.dropdownVisible, @@ -116,13 +115,16 @@ export default class YearDropdown extends Component< handleYearChange = ( date: Date, - event?: React.MouseEvent, + event?: React.MouseEvent, ): void => { this.onSelect?.(date, event); this.setOpen(); }; - onSelect = (date: Date, event?: React.MouseEvent): void => { + onSelect = ( + date: Date, + event?: React.MouseEvent, + ): void => { this.props.onSelect?.(date, event); }; diff --git a/src/year_dropdown_options.tsx b/src/year_dropdown_options.tsx index fcd9918501..773419b4d4 100644 --- a/src/year_dropdown_options.tsx +++ b/src/year_dropdown_options.tsx @@ -88,11 +88,46 @@ export default class YearDropdownOptions extends Component< } dropdownRef: React.RefObject; + yearOptionButtonsRef: Record = {}; + + 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); + // Add bounds checking to ensure the year exists in our refs + if (this.yearOptionButtonsRef[newYear]) { + this.yearOptionButtonsRef[newYear]?.focus(); + } + break; + } + } + }; renderOptions = (): React.ReactElement[] => { + // Clear refs to prevent memory leaks on re-render + this.yearOptionButtonsRef = {}; + const selectedYear = this.props.year; const options = this.state.yearsList.map((year) => (
{ + 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" @@ -100,6 +135,7 @@ export default class YearDropdownOptions extends Component< } key={year} onClick={this.onChange.bind(this, year)} + onKeyDown={this.handleOptionKeyDown.bind(this, year)} aria-selected={selectedYear === year ? "true" : undefined} > {selectedYear === year ? (