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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user