Move Room List to ListView

- Also remove Space/Enter handing from keyboard navigation we can just leave the default behaviour of those keys and handle via onClick
This commit is contained in:
David Langley 2025-07-31 19:27:50 +01:00
parent 876bb7dff9
commit 654aade19a
7 changed files with 136 additions and 209 deletions

View File

@ -15,40 +15,44 @@
* |-------------------------------------------------------|
*/
.mx_RoomListItemView {
all: unset;
/* Remove button default style */
background: unset;
border: none;
padding: 0;
text-align: unset;
cursor: pointer;
height: 48px;
width: 100%;
.mx_RoomListItemView_container {
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
.mx_RoomListItemView_content {
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
min-width: 0;
padding-right: var(--cpd-space-5x);
.mx_RoomListItemView_content {
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
.mx_RoomListItemView_text {
min-width: 0;
padding-right: var(--cpd-space-5x);
}
.mx_RoomListItemView_text {
min-width: 0;
}
.mx_RoomListItemView_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@ -57,7 +61,7 @@
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.mx_RoomListItemView_menu_open .mx_RoomListItemView_container .mx_RoomListItemView_content {
.mx_RoomListItemView_menu_open .mx_RoomListItemView_content {
/**
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
* the icon size of the menu is 18px instead of 20px with a different internal padding

View File

@ -29,12 +29,6 @@ export interface IListViewProps<Item, Context>
*/
items: Item[];
/**
* Callback function called when an item is selected (via Enter/Space key).
* @param item - The selected item from the items array
*/
onSelectItem: (item: Item) => void;
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
@ -74,7 +68,7 @@ export interface IListViewProps<Item, Context>
*/
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
const { items, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
@ -181,10 +175,6 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if ((e.code === "Enter" || e.code === "Space") && currentIndex !== undefined) {
const item = items[currentIndex];
onSelectItem(item);
handled = true;
} else if (e.code === "Home") {
scrollToIndex(0);
handled = true;
@ -206,7 +196,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
e.preventDefault();
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem],
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items],
);
/**
@ -242,8 +232,12 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((): void => {
setIsFocused(false);
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
// Only set isFocused to false if the focus is moving outside the list
// This prevents the list from losing focus when interacting with menus inside it
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsFocused(false);
}
}, []);
const listContext: ListContext<Context> = {

View File

@ -105,7 +105,6 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
</Form.Root>
<ListView
items={vm.members}
onSelectItem={handleSelectItem}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={isItemFocusable}

View File

@ -6,15 +6,12 @@
*/
import React, { useCallback, type JSX } from "react";
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";
import { ListContext, ListView } from "../../../utils/ListView";
import { Room } from "matrix-js-sdk/src/matrix";
interface RoomListProps {
/**
@ -27,54 +24,40 @@ interface RoomListProps {
* A virtualized list of rooms.
*/
export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListItemView room={rooms[index]} key={key} style={style} isSelected={activeIndex === index} />
),
const getItemComponent = useCallback(
(index: number, item: Room, context: ListContext<any>): JSX.Element => {
const itemKey = item.roomId;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeIndex === index;
return (
<RoomListItemView
room={item}
key={itemKey}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={isRovingItem ? 0 : -1}
/>
);
},
[rooms, activeIndex],
);
const getItemKey = useCallback((item: Room): string => {
return item.roomId;
}, []);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly
return (
<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>
<ListView
data-testid="room-list"
role="grid"
aria-label={_t("room_list|list_title")}
fixedItemHeight={48}
items={rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
/>
);
}

View File

@ -46,7 +46,13 @@ export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuView
const vm = useRoomListItemMenuViewModel(room);
return (
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-1x)">
<Flex
className="mx_RoomListItemMenuView"
align="center"
gap="var(--cpd-space-1x)"
// We don't want keyboard navigation event to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
{vm.showNotificationMenu && <NotificationMenu setMenuOpen={setMenuOpen} vm={vm} />}
</Flex>

View File

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useCallback, useRef, useState } from "react";
import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
@ -14,7 +14,6 @@ import { Flex } from "../../../../shared-components/utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
@ -26,6 +25,10 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
* Whether the room is selected
*/
isSelected: boolean;
/**
* Whether the room is focused
*/
isFocused: boolean;
}
/**
@ -34,18 +37,16 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
...props
}: RoomListItemViewProps): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null);
const [onFocus, isActive, ref] = useRovingTabIndex(buttonRef);
const ref = useRef<HTMLButtonElement>(null);
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHoverWithDelay] = useIsHover();
const [isHover, setHover] = useState(false);
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;
const showHoverDecoration = isMenuOpen || isFocused || isHover;
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
const closeMenu = useCallback(() => {
@ -54,8 +55,15 @@ export const RoomListItemView = memo(function RoomListItemView({
setTimeout(() => setIsMenuOpen(false), 10);
}, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = (
<button
<Flex
as="button"
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_hover: showHoverDecoration,
@ -63,63 +71,55 @@ export const RoomListItemView = memo(function RoomListItemView({
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="gridcell"
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
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}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
tabIndex={isFocused ? 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 */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
align="center"
justify="space-between"
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className="mx_RoomListItemView_text">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
</Flex>
</div>
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)}
</Flex>
</button>
</Flex>
);
if (!vm.showContextMenu) return content;
@ -140,33 +140,3 @@ export const RoomListItemView = memo(function RoomListItemView({
</RoomListItemContextMenuView>
);
});
/**
* 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];
}

View File

@ -21,7 +21,6 @@ const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
describe("ListView", () => {
const mockOnSelectItem = jest.fn();
const mockGetItemComponent = jest.fn();
const mockIsItemFocusable = jest.fn();
@ -34,7 +33,6 @@ describe("ListView", () => {
const defaultProps: IListViewProps<TestItemWithSeparator, any> = {
items: defaultItems,
onSelectItem: mockOnSelectItem,
getItemComponent: mockGetItemComponent,
isItemFocusable: mockIsItemFocusable,
getItemKey: (item) => (typeof item === "string" ? item : item.id),
@ -87,33 +85,6 @@ describe("ListView", () => {
});
describe("Keyboard Navigation", () => {
it("should handle Enter key and call onSelectItem when focused", async () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
// Focus to activate the list and navigate to first focusable item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "Enter" });
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
});
it("should handle Space key and call onSelectItem when focused", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
// Focus to activate the list and navigate to first focusable item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "Space" });
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
});
it("should handle ArrowDown key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");