From 34450d513a1627b676e2354939cfd8b8274203a9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 12 Sep 2025 10:24:56 +0100 Subject: [PATCH] Make landmark navigation work with new room list (#30747) * Make landmark navigation work with new room list Split out from https://github.com/element-hq/element-web/pull/30640 * Fix landmark selection to work with either room list * Add test for landmark navigation * Add test * Fix test * Clear mocks between runs --- src/accessibility/LandmarkNavigation.ts | 13 +++-- .../rooms/RoomListPanel/RoomListPanel.tsx | 34 +++++++++++- .../RoomListPanel/RoomListPanel-test.tsx | 52 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/accessibility/LandmarkNavigation.ts b/src/accessibility/LandmarkNavigation.ts index 1c6fe54345..404e01e99c 100644 --- a/src/accessibility/LandmarkNavigation.ts +++ b/src/accessibility/LandmarkNavigation.ts @@ -9,6 +9,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import { Action } from "../dispatcher/actions"; import defaultDispatcher from "../dispatcher/dispatcher"; +import SettingsStore from "../settings/SettingsStore"; export const enum Landmark { // This is the space/home button in the left panel. @@ -72,10 +73,16 @@ export class LandmarkNavigation { const landmarkToDomElementMap: Record HTMLElement | null | undefined> = { [Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector(".mx_SpaceButton_active"), - [Landmark.ROOM_SEARCH]: () => document.querySelector(".mx_RoomSearch"), + [Landmark.ROOM_SEARCH]: () => + SettingsStore.getValue("feature_new_room_list") + ? document.querySelector(".mx_RoomListSearch_search") + : document.querySelector(".mx_RoomSearch"), [Landmark.ROOM_LIST]: () => - document.querySelector(".mx_RoomTile_selected") || - document.querySelector(".mx_RoomTile"), + SettingsStore.getValue("feature_new_room_list") + ? document.querySelector(".mx_RoomListItemView_selected") || + document.querySelector(".mx_RoomListItemView") + : document.querySelector(".mx_RoomTile_selected") || + document.querySelector(".mx_RoomTile"), [Landmark.MESSAGE_COMPOSER_OR_HOME]: () => { const isComposerOpen = !!document.querySelector(".mx_MessageComposer"); diff --git a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index 8673463ef8..c9c810a28b 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { useState, useCallback } from "react"; import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../../settings/UIFeature"; @@ -14,6 +14,10 @@ import { RoomListHeaderView } from "./RoomListHeaderView"; import { RoomListView } from "./RoomListView"; import { Flex } from "../../../../shared-components/utils/Flex"; import { _t } from "../../../../languageHandler"; +import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; +import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; +import { type IState as IRovingTabIndexState } from "../../../../accessibility/RovingTabIndex"; type RoomListPanelProps = { /** @@ -28,6 +32,31 @@ type RoomListPanelProps = { */ export const RoomListPanel: React.FC = ({ activeSpace }) => { const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer); + const [focusedElement, setFocusedElement] = useState(null); + + const onFocus = useCallback((ev: React.FocusEvent): void => { + setFocusedElement(ev.target as Element); + }, []); + + const onBlur = useCallback((): void => { + setFocusedElement(null); + }, []); + + const onKeyDown = useCallback( + (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => { + if (!focusedElement) return; + const navAction = getKeyBindingsManager().getNavigationAction(ev); + if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) { + ev.stopPropagation(); + ev.preventDefault(); + LandmarkNavigation.findAndFocusNextLandmark( + Landmark.ROOM_SEARCH, + navAction === KeyBindingAction.PreviousLandmark, + ); + } + }, + [focusedElement], + ); return ( = ({ activeSpace }) => direction="column" align="stretch" aria-label={_t("room_list|list_title")} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown} > {displayRoomSearch && } diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPanel-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPanel-test.tsx index 2b2cb34185..79148d5041 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPanel-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListPanel-test.tsx @@ -8,21 +8,39 @@ import React from "react"; import { render, screen } from "jest-matrix-react"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import { RoomListPanel } from "../../../../../../src/components/views/rooms/RoomListPanel"; import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; import { MetaSpace } from "../../../../../../src/stores/spaces"; +import { LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation"; +import { ReleaseAnnouncementStore } from "../../../../../../src/stores/ReleaseAnnouncementStore"; jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), })); +jest.mock("../../../../../../src/accessibility/LandmarkNavigation", () => ({ + LandmarkNavigation: { + findAndFocusNextLandmark: jest.fn(), + }, + Landmark: { + ROOM_SEARCH: "something", + }, +})); + +// mock out release announcements as they interfere with what's focused +// (this can be removed once the new room list announcement is gone) +jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(null); + describe("", () => { function renderComponent() { return render(); } beforeEach(() => { + jest.clearAllMocks(); + // By default, we consider shouldShowComponent(UIComponent.FilterContainer) should return true mocked(shouldShowComponent).mockReturnValue(true); }); @@ -37,4 +55,38 @@ describe("", () => { renderComponent(); expect(screen.queryByRole("button", { name: "Search Ctrl K" })).toBeNull(); }); + + it("should move to the next landmark when the shortcut key is pressed", async () => { + renderComponent(); + + const userEv = userEvent.setup(); + + // Pick something arbitrary and focusable in the room list component and focus it + const exploreRooms = screen.getByRole("button", { name: "Explore rooms" }); + exploreRooms.focus(); + expect(exploreRooms).toHaveFocus(); + + screen.getByRole("navigation", { name: "Room list" }).focus(); + await userEv.keyboard("{Control>}{F6}{/Control}"); + + expect(LandmarkNavigation.findAndFocusNextLandmark).toHaveBeenCalled(); + }); + + it("should not move to the next landmark if room list loses focus", async () => { + renderComponent(); + + const userEv = userEvent.setup(); + + // Pick something arbitrary and focusable in the room list component and focus it + const exploreRooms = screen.getByRole("button", { name: "Explore rooms" }); + exploreRooms.focus(); + expect(exploreRooms).toHaveFocus(); + + exploreRooms.blur(); + expect(exploreRooms).not.toHaveFocus(); + + await userEv.keyboard("{Control>}{F6}{/Control}"); + + expect(LandmarkNavigation.findAndFocusNextLandmark).not.toHaveBeenCalled(); + }); });