diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index 999c07a811..1ab563d43a 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -65,6 +65,7 @@ class MenuOption extends React.Component { role="option" aria-selected={this.props.highlighted} ref={this.props.inputRef} + tabIndex={-1} > {this.props.children} @@ -232,18 +233,27 @@ export default class Dropdown extends React.Component { break; case KeyBindingAction.ArrowDown: if (this.state.expanded) { + const nextKey = this.nextOption(this.state.highlightedOption); this.setState({ - highlightedOption: this.nextOption(this.state.highlightedOption), + highlightedOption: nextKey, }); + + ( + this.dropdownRootElement?.querySelector(`#${this.props.id}__${nextKey}`) as HTMLLIElement + )?.focus(); } else { this.setState({ expanded: true }); } break; case KeyBindingAction.ArrowUp: if (this.state.expanded) { + const prevKey = this.prevOption(this.state.highlightedOption); this.setState({ - highlightedOption: this.prevOption(this.state.highlightedOption), + highlightedOption: prevKey, }); + ( + this.dropdownRootElement?.querySelector(`#${this.props.id}__${prevKey}`) as HTMLLIElement + )?.focus(); } else { this.setState({ expanded: true }); } diff --git a/test/unit-tests/components/views/elements/Dropdown-test.tsx b/test/unit-tests/components/views/elements/Dropdown-test.tsx new file mode 100644 index 0000000000..ca428aff6e --- /dev/null +++ b/test/unit-tests/components/views/elements/Dropdown-test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type ReactElement } from "react"; +import { render, screen, fireEvent } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import Dropdown from "../../../../../src/components/views/elements/Dropdown"; +import type { NonEmptyArray } from "../../../../../src/@types/common"; + +describe("", () => { + const placeholder = "Select an option"; + const onOptionChange = jest.fn(); + + function renderDropdown(props?: Partial>) { + return render( + + { + [
one
,
two
,
three
] as NonEmptyArray< + ReactElement & { key: string } + > + } +
, + ); + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders with placeholder", () => { + const { asFragment } = renderDropdown(); + expect(screen.getByText(placeholder)).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("expands and collapses on click", async () => { + const user = userEvent.setup(); + renderDropdown(); + + const button = screen.getByRole("button"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + await user.click(button); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + // Collapse by clicking outside + await user.click(document.body); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("calls onOptionChange when an option is selected", async () => { + renderDropdown(); + + await userEvent.click(screen.getByRole("button")); + const option = screen.getByRole("option", { name: "two" }); + await userEvent.click(option); + expect(onOptionChange).toHaveBeenCalledWith("two"); + }); + + it("handles keyboard navigation and selection", async () => { + renderDropdown(); + + const button = screen.getByRole("button"); + await userEvent.click(button); + // Arrow down to "two" + fireEvent.keyDown(button, { key: "ArrowDown" }); + expect(screen.getByRole("option", { name: "two" })).toHaveFocus(); + // Arrow up to "one" + fireEvent.keyDown(button, { key: "ArrowUp" }); + expect(screen.getByRole("option", { name: "one" })).toHaveFocus(); + // Enter to select + fireEvent.keyDown(button, { key: "Enter" }); + expect(onOptionChange).toHaveBeenCalledWith("one"); + }); + + it("does not open when disabled", async () => { + renderDropdown({ disabled: true }); + + const button = screen.getByRole("button"); + await userEvent.click(button); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/components/views/elements/__snapshots__/Dropdown-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/Dropdown-test.tsx.snap new file mode 100644 index 0000000000..a89f111df4 --- /dev/null +++ b/test/unit-tests/components/views/elements/__snapshots__/Dropdown-test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders with placeholder 1`] = ` + +
+ +
+
+`; diff --git a/test/unit-tests/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap index 3f6d46f2fe..655cb2467e 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap @@ -11,6 +11,7 @@ exports[` renders dropdown options in menu 1`] = ` class="mx_Dropdown_option mx_Dropdown_option_highlight" id="test__one" role="option" + tabindex="-1" >
renders dropdown options in menu 1`] = ` class="mx_Dropdown_option" id="test__two" role="option" + tabindex="-1" >