New room list: add keyboard navigation support (#29805)

* feat: support up/down arrow navigation in the new room list

* feat: support tabbing in the new room list

* test: update snapshots

* test(e2e): fix room list test

* test(new room list): add landmark navigation test

* test(e2e): update screenshot test

* test: add test to `RoomListItemView`

* test(e2e): add keyboard navigation tests

* refactor: rename `setIsHover` on `setIsHoverWithDelay`
This commit is contained in:
Florian Duros
2025-05-06 18:09:23 +02:00
committed by GitHub
parent 6ba21dafa7
commit 74fbd892a1
9 changed files with 211 additions and 47 deletions

View File

@@ -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 (
<div className="mx_RoomList" data-testid="room-list">
<AutoSizer>
{({ height, width }) => (
<List
aria-label={_t("room_list|list_title")}
className="mx_RoomList_List"
rowRenderer={roomRendererMemoized}
rowCount={rooms.length}
rowHeight={48}
height={height}
width={width}
scrollToIndex={activeIndex ?? 0}
/>
)}
</AutoSizer>
</div>
<RovingTabIndexProvider handleHomeEnd={true} handleUpDown={true}>
{({ onKeyDownHandler }) => (
<div
className="mx_RoomList"
data-testid="room-list"
onKeyDown={(ev) => {
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);
}}
>
<AutoSizer>
{({ height, width }) => (
<List
aria-label={_t("room_list|list_title")}
className="mx_RoomList_List"
rowRenderer={roomRendererMemoized}
rowCount={rooms.length}
rowHeight={48}
height={height}
width={width}
scrollToIndex={activeIndex ?? 0}
tabIndex={-1}
/>
)}
</AutoSizer>
</div>
)}
</RovingTabIndexProvider>
);
}

View File

@@ -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<HTMLButtonElement> {
/**
@@ -34,22 +35,29 @@ export const RoomListItemView = memo(function RoomListItemView({
isSelected,
...props
}: RoomListItemViewProps): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(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 (
<button
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_empty: !isNotificationDecorationVisible && !showHoverDecoration,
mx_RoomListItemView_notification_decoration: isNotificationDecorationVisible,
mx_RoomListItemView_menu_open: showHoverDecoration,
mx_RoomListItemView_hover: showHoverDecoration,
mx_RoomListItemView_menu_open: showHoverMenu,
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
@@ -57,10 +65,17 @@ export const RoomListItemView = memo(function RoomListItemView({
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHover(true)}
onMouseOut={() => setIsHover(false)}
onFocus={() => setIsHover(true)}
onBlur={() => setIsHover(false)}
onMouseOver={() => setIsHoverWithDelay(true)}
onMouseOut={() => setIsHoverWithDelay(false)}
onFocus={() => {
setIsHoverWithDelay(true);
onFocus();
}}
// Adding a timeout because when tabbing to go to the more options and notification menu, the focus moves out of the button
// The blur makes the button lose the hover state and these menu are not shown
// We delay the blur event to give time to the focus to move to the menu
onBlur={() => setIsHoverWithDelay(false, 10)}
tabIndex={isActive ? 0 : -1}
{...props}
>
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
@@ -79,13 +94,19 @@ export const RoomListItemView = memo(function RoomListItemView({
</div>
<div className="mx_RoomListItemView_messagePreview">{vm.messagePreview}</div>
</div>
{showHoverDecoration ? (
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => {
if (isOpen) setIsMenuOpen(isOpen);
// To avoid icon blinking when closing the menu, we delay the state update
else setTimeout(() => setIsMenuOpen(isOpen), 0);
if (isOpen) {
setIsMenuOpen(isOpen);
} else {
// To avoid icon blinking when closing the menu, we delay the state update
setTimeout(() => setIsMenuOpen(isOpen), 0);
// After closing the menu, we need to set the focus back to the button
// 10ms because the focus moves to the body and we put back the focus on the button
setTimeout(() => buttonRef.current?.focus(), 10);
}
}}
/>
) : (
@@ -105,3 +126,33 @@ export const RoomListItemView = memo(function RoomListItemView({
</button>
);
});
/**
* 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<number | undefined>(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];
}