Skip to content
Merged
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
13 changes: 10 additions & 3 deletions src/accessibility/LandmarkNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -72,10 +73,16 @@ export class LandmarkNavigation {
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),

[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_SEARCH]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListSearch_search")
: document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () =>
document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView")
: document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),

[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");
Expand Down
34 changes: 33 additions & 1 deletion src/components/views/rooms/RoomListPanel/RoomListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
/**
Expand All @@ -28,6 +32,31 @@ type RoomListPanelProps = {
*/
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) => {
const displayRoomSearch = shouldShowComponent(UIComponent.FilterContainer);
const [focusedElement, setFocusedElement] = useState<Element | null>(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 (
<Flex
Expand All @@ -36,6 +65,9 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
direction="column"
align="stretch"
aria-label={_t("room_list|list_title")}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
<RoomListHeaderView />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<RoomListPanel />", () => {
function renderComponent() {
return render(<RoomListPanel activeSpace={MetaSpace.Home} />);
}

beforeEach(() => {
jest.clearAllMocks();

// By default, we consider shouldShowComponent(UIComponent.FilterContainer) should return true
mocked(shouldShowComponent).mockReturnValue(true);
});
Expand All @@ -37,4 +55,38 @@ describe("<RoomListPanel />", () => {
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();
});
});
Loading