diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
index a84a8b09d0..7400efa575 100644
--- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
+++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
@@ -43,7 +43,8 @@ test.describe("Room list", () => {
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png");
- await roomListView.hover();
+ // Put focus on the room list
+ await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
@@ -105,13 +106,10 @@ test.describe("Room list", () => {
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
- // Remove hover on the room list item
- await roomListView.hover();
-
- // Scroll to the bottom of the list
- await page.getByRole("grid", { name: "Room list" }).evaluate((e) => {
- e.scrollTop = e.scrollHeight;
- });
+ // Put focus on the room list
+ await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
+ // Scroll to the end of the room list
+ await page.mouse.wheel(0, 1000);
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -129,7 +127,8 @@ test.describe("Room list", () => {
test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
- await roomListView.hover();
+ // Put focus on the room list
+ await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
@@ -183,6 +182,57 @@ test.describe("Room list", () => {
await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible();
});
});
+
+ test.describe("Keyboard navigation", () => {
+ test("should navigate to the room list", async ({ page, app, user }) => {
+ const roomListView = getRoomList(page);
+
+ const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
+ const room28 = roomListView.getByRole("gridcell", { name: "Open room room28" });
+
+ // open the room
+ await room29.click();
+ // put focus back on the room list item
+ await room29.click();
+ await expect(room29).toBeFocused();
+
+ await page.keyboard.press("ArrowDown");
+ await expect(room28).toBeFocused();
+ await expect(room29).not.toBeFocused();
+
+ await page.keyboard.press("ArrowUp");
+ await expect(room29).toBeFocused();
+ await expect(room28).not.toBeFocused();
+ });
+
+ test("should navigate to the notification menu", async ({ page, app, user }) => {
+ const roomListView = getRoomList(page);
+ const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
+ const moreButton = room29.getByRole("button", { name: "More options" });
+ const notificationButton = room29.getByRole("button", { name: "Notification options" });
+
+ await room29.click();
+ // put focus back on the room list item
+ await room29.click();
+ await page.keyboard.press("Tab");
+ await expect(moreButton).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(notificationButton).toBeFocused();
+
+ // Open the menu
+ await notificationButton.click();
+ // Wait for the menu to be open
+ await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ // Close the menu
+ await page.keyboard.press("Escape");
+ // Focus should be back on the room list item
+ await expect(room29).toBeFocused();
+ });
+ });
});
test.describe("Avatar decoration", () => {
diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png
index d9b8a8a3ad..33a0d9f111 100644
Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ
diff --git a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss
index 1e61bf7f3c..01526551ce 100644
--- a/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss
+++ b/res/css/views/rooms/RoomListPanel/_RoomListItemView.pcss
@@ -18,10 +18,6 @@
all: unset;
cursor: pointer;
- &:hover {
- background-color: var(--cpd-color-bg-action-secondary-hovered);
- }
-
.mx_RoomListItemView_container {
padding-left: var(--cpd-space-2x);
font: var(--cpd-font-body-md-regular);
@@ -56,12 +52,12 @@
}
}
-.mx_RoomListItemView_menu_open {
+.mx_RoomListItemView_hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
+}
- .mx_RoomListItemView_content {
- padding-right: var(--cpd-space-1-5x);
- }
+.mx_RoomListItemView_menu_open .mx_RoomListItemView_content {
+ padding-right: var(--cpd-space-1-5x);
}
.mx_RoomListItemView_selected {
diff --git a/src/components/views/rooms/RoomListPanel/RoomList.tsx b/src/components/views/rooms/RoomListPanel/RoomList.tsx
index 628723246f..f5f610f5ae 100644
--- a/src/components/views/rooms/RoomListPanel/RoomList.tsx
+++ b/src/components/views/rooms/RoomListPanel/RoomList.tsx
@@ -11,6 +11,10 @@ import { AutoSizer, List, type ListRowProps } from "react-virtualized";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
+import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
+import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
+import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
+import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
interface RoomListProps {
/**
@@ -32,21 +36,45 @@ export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Ele
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly
return (
-
-
- {({ height, width }) => (
-
- )}
-
-
+
+ {({ onKeyDownHandler }) => (
+ {
+ const navAction = getKeyBindingsManager().getNavigationAction(ev);
+ if (
+ navAction === KeyBindingAction.NextLandmark ||
+ navAction === KeyBindingAction.PreviousLandmark
+ ) {
+ LandmarkNavigation.findAndFocusNextLandmark(
+ Landmark.ROOM_LIST,
+ navAction === KeyBindingAction.PreviousLandmark,
+ );
+ ev.stopPropagation();
+ ev.preventDefault();
+ return;
+ }
+ onKeyDownHandler(ev);
+ }}
+ >
+
+ {({ height, width }) => (
+
+ )}
+
+
+ )}
+
);
}
diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx
index ce77f5eb76..1e19b22026 100644
--- a/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx
+++ b/src/components/views/rooms/RoomListPanel/RoomListItemView.tsx
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-import React, { type JSX, memo, useState } from "react";
+import React, { type JSX, memo, useCallback, useRef, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
@@ -14,6 +14,7 @@ import { Flex } from "../../../utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
+import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
interface RoomListItemViewProps extends React.HTMLAttributes {
/**
@@ -34,22 +35,29 @@ export const RoomListItemView = memo(function RoomListItemView({
isSelected,
...props
}: RoomListItemViewProps): JSX.Element {
+ const buttonRef = useRef(null);
+ const [onFocus, isActive, ref] = useRovingTabIndex(buttonRef);
+
const vm = useRoomListItemViewModel(room);
- const [isHover, setIsHover] = useState(false);
+ const [isHover, setIsHoverWithDelay] = useIsHover();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
- const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
+ const showHoverDecoration = isMenuOpen || isHover;
+ const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
- const isNotificationDecorationVisible = !showHoverDecoration && vm.showNotificationDecoration;
+ const isInvitation = vm.notificationState.invited;
+ const isNotificationDecorationVisible = isInvitation || (!showHoverDecoration && vm.showNotificationDecoration);
return (
);
});
+
+/**
+ * Custom hook to manage the hover state of the room list item
+ * If the timeout is set, it will set the hover state after the timeout
+ * If the timeout is not set, it will set the hover state immediately
+ * When the set method is called, it will clear any existing timeout
+ *
+ * @returns {boolean} isHover - The hover state
+ */
+function useIsHover(): [boolean, (value: boolean, timeout?: number) => void] {
+ const [isHover, setIsHover] = useState(false);
+ // Store the timeout ID
+ const timeoutRef = useRef(undefined);
+
+ const setIsHoverWithDelay = useCallback((value: boolean, timeout?: number): void => {
+ // Clear the timeout if it exists
+ clearTimeout(timeoutRef.current);
+
+ // No delay, set the value immediately
+ if (timeout === undefined) {
+ setIsHover(value);
+ return;
+ }
+
+ // Set a timeout to set the value after the delay
+ timeoutRef.current = setTimeout(() => setIsHover(value), timeout);
+ }, []);
+
+ return [isHover, setIsHoverWithDelay];
+}
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
index d72a78d36a..61a0ec46aa 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomList-test.tsx
@@ -8,6 +8,7 @@
import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react";
+import { fireEvent } from "@testing-library/dom";
import { mkRoom, stubClient } from "../../../../../test-utils";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
@@ -15,6 +16,7 @@ import { RoomList } from "../../../../../../src/components/views/rooms/RoomListP
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
import { SortOption } from "../../../../../../src/components/viewmodels/roomlist/useSorter";
+import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation";
describe("", () => {
let matrixClient: MatrixClient;
@@ -53,4 +55,16 @@ describe("", () => {
const { asFragment } = render();
expect(asFragment()).toMatchSnapshot();
});
+
+ it.each([
+ { shortcut: { key: "F6", ctrlKey: true, shiftKey: true }, isPreviousLandmark: true, label: "PreviousLandmark" },
+ { shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
+ ])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
+ const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
+ const { getByTestId } = render();
+ const roomList = getByTestId("room-list");
+ fireEvent.keyDown(roomList, shortcut);
+
+ expect(spyFindLandmark).toHaveBeenCalledWith(Landmark.ROOM_LIST, isPreviousLandmark);
+ });
});
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx
index 472258374e..914d33ee18 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListItemView-test.tsx
@@ -91,6 +91,17 @@ describe("", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument());
});
+ test("should hover decoration if focused", async () => {
+ const user = userEvent.setup();
+ render(, withClientContextRenderOptions(matrixClient));
+ const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
+ await user.click(listItem);
+ expect(listItem).toHaveClass("mx_RoomListItemView_hover");
+
+ await user.tab();
+ await waitFor(() => expect(listItem).not.toHaveClass("mx_RoomListItemView_hover"));
+ });
+
test("should be selected if isSelected=true", async () => {
const { asFragment } = render();
expect(screen.queryByRole("button", { name: `Open room ${room.name}` })).toHaveAttribute(
diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap
index 85511067cd..148b3d44d7 100644
--- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap
+++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/RoomList-test.tsx.snap
@@ -15,7 +15,7 @@ exports[` should render a room list 1`] = `
class="ReactVirtualized__Grid ReactVirtualized__List mx_RoomList_List"
role="grid"
style="box-sizing: border-box; direction: ltr; height: 1500px; position: relative; width: 1500px; will-change: transform; overflow-x: hidden; overflow-y: hidden;"
- tabindex="0"
+ tabindex="-1"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 0px; width: 100%;"
+ tabindex="0"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 48px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 96px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 144px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 192px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 240px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 288px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 336px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 384px; width: 100%;"
+ tabindex="-1"
type="button"
>
should render a room list 1`] = `
class="mx_RoomListItemView mx_RoomListItemView_empty"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 432px; width: 100%;"
+ tabindex="-1"
type="button"
>
should be selected if isSelected=true 1`] = `
aria-label="Open room room1"
aria-selected="true"
class="mx_RoomListItemView mx_RoomListItemView_empty mx_RoomListItemView_selected"
+ tabindex="-1"
type="button"
>
should display notification decoration 1`] = `
aria-label="Open room room1"
aria-selected="false"
class="mx_RoomListItemView mx_RoomListItemView_notification_decoration"
+ tabindex="-1"
type="button"
>
should render a room item 1`] = `
aria-label="Open room room1"
aria-selected="false"
class="mx_RoomListItemView mx_RoomListItemView_empty"
+ tabindex="-1"
type="button"
>
should render a room item with a message preview 1
aria-label="Open room room1"
aria-selected="false"
class="mx_RoomListItemView mx_RoomListItemView_empty"
+ tabindex="-1"
type="button"
>