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" >