Fix errors

Migrate RoomList
This commit is contained in:
David Langley 2025-11-26 11:17:21 +00:00
parent e7eeb98c9c
commit fa981e37c4
57 changed files with 4197 additions and 207 deletions

View File

@ -0,0 +1,9 @@
<testExecutions version="1">
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx">
<testCase name="RoomListHeader renders title" duration="153"/>
<testCase name="RoomListHeader renders title with action" duration="7"/>
<testCase name="RoomListHeader renders action buttons" duration="6"/>
<testCase name="RoomListHeader truncates long titles with title attribute" duration="15"/>
<testCase name="RoomListHeader renders data-testid attribute" duration="5"/>
</file>
</testExecutions>

View File

@ -52,6 +52,7 @@
"matrix-web-i18n": "^3.4.0",
"patch-package": "^8.0.1",
"react-merge-refs": "^3.0.2",
"react-virtuoso": "^4.15.0",
"temporal-polyfill": "^0.3.0"
},
"devDependencies": {

View File

@ -13,12 +13,21 @@ export * from "./audio/SeekBar";
export * from "./avatar/AvatarWithDetails";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./notifications/NotificationDecoration";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";
export * from "./rich-list/RichItem";
export * from "./rich-list/RichList";
export * from "./room-list/RoomList";
export * from "./room-list/RoomListItem";
export * from "./room-list/RoomListPanel";
export * from "./room-list/RoomListSearch";
export * from "./room-list/RoomListHeader";
export * from "./room-list/RoomListView";
export * from "./room-list/RoomListPrimaryFilters";
export * from "./utils/Box";
export * from "./utils/Flex";
export * from "./utils/ListView";
// Utils
export * from "./utils/i18n";

View File

@ -0,0 +1,79 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
/**
* ViewModel representing the notification state for a room or item
*/
export interface NotificationDecorationViewModel {
/** Whether there is any notification or activity to display */
hasAnyNotificationOrActivity: boolean;
/** Whether there's an unsent message */
isUnsentMessage: boolean;
/** Whether the user is invited to the room */
invited: boolean;
/** Whether the notification is a mention */
isMention: boolean;
/** Whether there's activity (not a full notification) */
isActivityNotification: boolean;
/** Whether there's a notification (not just activity) */
isNotification: boolean;
/** Notification count */
count: number;
/** Whether notifications are muted */
muted: boolean;
/** Optional call type indicator */
callType?: "video" | "voice";
}
export interface NotificationDecorationProps {
/** ViewModel containing notification state */
viewModel: NotificationDecorationViewModel;
}
/**
* Renders notification badges and indicators for rooms/items
*/
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({ viewModel }) => {
// Don't render anything if there's nothing to show
if (!viewModel.hasAnyNotificationOrActivity && !viewModel.muted && !viewModel.callType) {
return null;
}
return (
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
{viewModel.isUnsentMessage && (
<ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
)}
{viewModel.callType === "video" && (
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{viewModel.callType === "voice" && (
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{viewModel.invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{viewModel.isMention && (
<MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
)}
{(viewModel.isMention || viewModel.isNotification) && <UnreadCounter count={viewModel.count || null} />}
{viewModel.isActivityNotification && <Unread />}
{viewModel.muted && (
<NotificationOffIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />
)}
</Flex>
);
};

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { NotificationDecoration } from "./NotificationDecoration";
export type { NotificationDecorationProps, NotificationDecorationViewModel } from "./NotificationDecoration";

View File

@ -0,0 +1,21 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
/**
* Notification state for a room.
* Matches the element-web RoomNotifState enum.
*/
export enum RoomNotifState {
/** All messages (default) */
AllMessages = "all_messages",
/** All messages with sound */
AllMessagesLoud = "all_messages_loud",
/** Only mentions and keywords */
MentionsOnly = "mentions_only",
/** Muted */
Mute = "mute",
}

View File

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
/**
* Room list container styles
* The actual room items are styled by the consumer (element-web)
*/
.roomList {
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,189 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
import { type RoomNotifState } from "../../notifications/RoomNotifs";
import type { Meta, StoryObj } from "@storybook/react-vite";
// Mock avatar component
const mockAvatar = (name: string): React.ReactElement => (
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: "#0dbd8b",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: "12px",
}}
>
{name.substring(0, 2).toUpperCase()}
</div>
);
// Generate mock rooms with ViewModels
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log("Mark as read"),
markAsUnread: () => console.log("Mark as unread"),
toggleFavorite: () => console.log("Toggle favorite"),
toggleLowPriority: () => console.log("Toggle low priority"),
invite: () => console.log("Invite"),
copyRoomLink: () => console.log("Copy room link"),
leaveRoom: () => console.log("Leave room"),
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
return Array.from({ length: count }, (_, i) => {
const hasUnread = Math.random() > 0.7;
const unreadCount = hasUnread ? Math.floor(Math.random() * 10) : 0;
const hasNotification = Math.random() > 0.8;
const isMention = Math.random() > 0.9;
const notificationViewModel: NotificationDecorationViewModel = hasUnread
? {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention,
isActivityNotification: !hasNotification,
isNotification: hasNotification,
count: unreadCount,
muted: false,
}
: mockNotificationViewModel;
return {
id: `!room${i}:server`,
name: `Room ${i + 1}`,
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
a11yLabel: unreadCount > 0 ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
isBold: unreadCount > 0,
messagePreview: undefined,
notificationViewModel,
menuViewModel: mockMenuViewModel,
};
});
};
const mockRoomsResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(50),
};
const mockViewModel: RoomListViewModel = {
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
};
const renderAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => {
return mockAvatar(roomViewModel.name);
};
const meta = {
title: "Room List/RoomList",
component: RoomList,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ height: "600px", border: "1px solid #ccc" }}>
<Story />
</div>
),
],
args: {
viewModel: mockViewModel,
renderAvatar,
},
} satisfies Meta<typeof RoomList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithSelection: Story = {
args: {
viewModel: {
...mockViewModel,
activeRoomIndex: 5,
},
},
};
export const SmallList: Story = {
args: {
viewModel: {
...mockViewModel,
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(5),
},
},
},
};
export const LargeList: Story = {
args: {
viewModel: {
...mockViewModel,
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(200),
},
},
},
};
export const EmptyList: Story = {
args: {
viewModel: {
...mockViewModel,
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
},
},
};

View File

@ -0,0 +1,174 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "@testing-library/react";
import React from "react";
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
import type { RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
describe("RoomList", () => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: jest.fn(),
markAsUnread: jest.fn(),
toggleFavorite: jest.fn(),
toggleLowPriority: jest.fn(),
invite: jest.fn(),
copyRoomLink: jest.fn(),
leaveRoom: jest.fn(),
setRoomNotifState: jest.fn(),
};
const mockRooms: RoomListItemViewModel[] = [
{
id: "!room1:server",
name: "Room 1",
openRoom: jest.fn(),
a11yLabel: "Room 1",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
},
{
id: "!room2:server",
name: "Room 2",
openRoom: jest.fn(),
a11yLabel: "Room 2",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
},
{
id: "!room3:server",
name: "Room 3",
openRoom: jest.fn(),
a11yLabel: "Room 3",
isBold: false,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
},
];
const mockRoomsResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: mockRooms,
};
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
));
const mockViewModel: RoomListViewModel = {
roomsResult: mockRoomsResult,
activeRoomIndex: undefined,
onKeyDown: undefined,
};
beforeEach(() => {
mockRenderAvatar.mockClear();
});
it("renders the room list with correct aria attributes", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute("data-testid", "room-list");
});
it("renders with correct aria-label", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute("aria-label");
});
it("calls renderAvatar for each room", () => {
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
// renderAvatar should be called for visible rooms (virtualization means not all may render immediately)
expect(mockRenderAvatar).toHaveBeenCalled();
});
it("handles empty room list", () => {
const emptyResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
};
const emptyViewModel: RoomListViewModel = {
...mockViewModel,
roomsResult: emptyResult,
};
render(<RoomList viewModel={emptyViewModel} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
});
it("passes activeRoomIndex correctly", () => {
const vmWithActive: RoomListViewModel = {
...mockViewModel,
activeRoomIndex: 1,
};
render(<RoomList viewModel={vmWithActive} renderAvatar={mockRenderAvatar} />);
// Component should render with active index set
const listbox = screen.getByRole("listbox");
expect(listbox).toBeInTheDocument();
});
it("handles keyboard events via onKeyDown callback", () => {
const onKeyDown = jest.fn();
const vmWithKeyDown: RoomListViewModel = {
...mockViewModel,
onKeyDown,
};
render(<RoomList viewModel={vmWithKeyDown} renderAvatar={mockRenderAvatar} />);
const listbox = screen.getByRole("listbox");
listbox.focus();
// Fire a keyboard event
const event = new KeyboardEvent("keydown", { key: "ArrowDown", code: "ArrowDown" });
listbox.dispatchEvent(event);
// onKeyDown should be called
expect(onKeyDown).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,183 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { _t } from "../../utils/i18n";
import { ListView, type ListContext } from "../../utils/ListView";
import { RoomListItem, type RoomListItemViewModel } from "../RoomListItem";
/**
* Filter key type - opaque string type for filter identifiers
*/
export type FilterKey = string;
/**
* Represents the result of a room query
*/
export interface RoomsResult {
/** The ID of the current space */
spaceId: string;
/** Active filter keys */
filterKeys: FilterKey[] | undefined;
/** Array of room item view models */
rooms: RoomListItemViewModel[];
}
/**
* ViewModel interface for RoomList
*/
export interface RoomListViewModel {
/** The rooms result containing the list of rooms */
roomsResult: RoomsResult;
/** Optional active room index */
activeRoomIndex?: number;
/** Optional keyboard event handler */
onKeyDown?: (ev: React.KeyboardEvent) => void;
}
/**
* Props for the RoomList component
*/
export interface RoomListProps {
/**
* The view model containing room list data
*/
viewModel: RoomListViewModel;
/**
* Render function for room avatar
* @param roomViewModel - The room item view model
*/
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
}
/** Height of a single room list item in pixels */
const ROOM_LIST_ITEM_HEIGHT = 48;
/**
* Amount to extend the top and bottom of the viewport by.
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space
* when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you
* can still see/read the content. Using the trackpad to sling through a large percentage of the
* list quickly will still show blank space. We would likely need to simplify the item content to
* improve this case.
*/
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
/**
* A virtualized list of rooms.
* This component provides efficient rendering of large room lists using virtualization,
* and renders RoomListItem components for each room.
*/
export function RoomList({ viewModel, renderAvatar }: RoomListProps): JSX.Element {
const { roomsResult, activeRoomIndex, onKeyDown } = viewModel;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
/**
* Get the item component for a specific index
*/
const getItemComponent = useCallback(
(
index: number,
item: RoomListItemViewModel,
context: ListContext<{
spaceId: string;
filterKeys: FilterKey[] | undefined;
}>,
onFocus: (item: RoomListItemViewModel, e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.id;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeRoomIndex === index;
return (
<div key={itemKey}>
<RoomListItem
viewModel={item}
isSelected={isSelected}
isFocused={isFocused}
onFocus={(e) => onFocus(item, e)}
roomIndex={index}
roomCount={roomCount}
avatar={renderAvatar(item)}
/>
</div>
);
},
[activeRoomIndex, roomCount, renderAvatar],
);
/**
* Get the key for a room item
*/
const getItemKey = useCallback((item: RoomListItemViewModel): string => {
return item.id;
}, []);
/**
* Determine if we should scroll the active index into view
* This happens when the space or filters change
*/
const scrollIntoViewOnChange = useCallback(
(params: {
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false | void => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: "start",
index: activeRoomIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeRoomIndex],
);
/**
* Handle keyboard events
*/
const keyDownCallback = useCallback(
(ev: React.KeyboardEvent): void => {
onKeyDown?.(ev);
},
[onKeyDown],
);
return (
<ListView
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeRoomIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomsResult.rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
/>
);
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomList } from "./RoomList";
export type { RoomListProps, RoomListViewModel, RoomsResult, FilterKey } from "./RoomList";

View File

@ -0,0 +1,86 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, type JSX } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import { _t } from "../../utils/i18n";
/**
* ViewModel for ComposeMenu
*/
export interface ComposeMenuViewModel {
/** Whether the user can create rooms */
canCreateRoom: boolean;
/** Whether the user can create video rooms */
canCreateVideoRoom: boolean;
/** Create a chat room */
createChatRoom: () => void;
/** Create a room */
createRoom: () => void;
/** Create a video room */
createVideoRoom: () => void;
}
/**
* Props for ComposeMenu component
*/
export interface ComposeMenuProps {
/** The view model containing menu data and callbacks */
viewModel: ComposeMenuViewModel;
}
/**
* The compose menu for the room list header.
* Displays a dropdown menu with options to create new chats, rooms, and video rooms.
*/
export const ComposeMenu: React.FC<ComposeMenuProps> = ({ viewModel }): JSX.Element => {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
showTitle={false}
title={_t("action|open_menu")}
side="right"
align="start"
trigger={
<IconButton tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
}
>
<MenuItem
Icon={ChatIcon}
label={_t("action|start_chat")}
onSelect={viewModel.createChatRoom}
hideChevron={true}
/>
{viewModel.canCreateRoom && (
<MenuItem
Icon={RoomIcon}
label={_t("action|new_room")}
onSelect={viewModel.createRoom}
hideChevron={true}
/>
)}
{viewModel.canCreateVideoRoom && (
<MenuItem
Icon={VideoCallIcon}
label={_t("action|new_video_room")}
onSelect={viewModel.createVideoRoom}
hideChevron={true}
/>
)}
</Menu>
);
};

View File

@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.roomListHeader {
flex: 0 0 60px;
padding: 0 var(--cpd-space-3x);
}
.title {
min-width: 0;
}
.title h1 {
all: unset;
font: var(--cpd-font-heading-sm-semibold);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Styles for element-web specific components */
.roomListHeader :global(.mx_SpaceMenu_button) svg {
transition: transform 0.1s linear;
}
.roomListHeader :global(.mx_SpaceMenu_button[aria-expanded="true"]) svg {
transform: rotate(180deg);
}
.roomListHeader :global(.mx_RoomListHeaderView_ReleaseAnnouncementAnchor) {
display: inline-flex;
}

View File

@ -0,0 +1,140 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListHeader } from "./RoomListHeader";
import { SortOption } from "./SortOptionsMenu";
import type { SpaceMenuViewModel } from "./SpaceMenu";
import type { ComposeMenuViewModel } from "./ComposeMenu";
const meta: Meta<typeof RoomListHeader> = {
title: "Room List/RoomListHeader",
component: RoomListHeader,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof RoomListHeader>;
const baseSortOptionsViewModel = {
activeSortOption: SortOption.Activity,
sort: (option: SortOption) => console.log("Sort by:", option),
};
export const Default: Story = {
args: {
viewModel: {
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
},
};
export const WithSpaceMenu: Story = {
args: {
viewModel: {
title: "My Space",
isSpace: true,
spaceMenuViewModel: {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose clicked"),
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
},
};
export const WithComposeMenu: Story = {
args: {
viewModel: {
title: "Home",
isSpace: false,
displayComposeMenu: true,
composeMenuViewModel: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
},
};
export const FullHeader: Story = {
args: {
viewModel: {
title: "My Space",
isSpace: true,
spaceMenuViewModel: {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: true,
composeMenuViewModel: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
},
};
export const LongTitle: Story = {
args: {
viewModel: {
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
isSpace: true,
spaceMenuViewModel: {
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: () => console.log("Open space home"),
inviteInSpace: () => console.log("Invite in space"),
openSpacePreferences: () => console.log("Open space preferences"),
openSpaceSettings: () => console.log("Open space settings"),
} as SpaceMenuViewModel,
displayComposeMenu: true,
composeMenuViewModel: {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: () => console.log("Create chat room"),
createRoom: () => console.log("Create room"),
createVideoRoom: () => console.log("Create video room"),
} as ComposeMenuViewModel,
sortOptionsMenuViewModel: baseSortOptionsViewModel,
},
},
decorators: [
(Story) => (
<div style={{ width: "320px" }}>
<Story />
</div>
),
],
};

View File

@ -0,0 +1,148 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListHeader, type RoomListHeaderViewModel } from "./RoomListHeader";
import type { SpaceMenuViewModel } from "./SpaceMenu";
import type { ComposeMenuViewModel } from "./ComposeMenu";
import type { SortOptionsMenuViewModel } from "./SortOptionsMenu";
import { SortOption } from "./SortOptionsMenu";
describe("RoomListHeader", () => {
const mockSortOptionsViewModel: SortOptionsMenuViewModel = {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
};
it("renders title", () => {
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
expect(screen.getByRole("banner")).toBeInTheDocument();
});
it("renders space menu when isSpace is true", () => {
const mockSpaceMenuViewModel: SpaceMenuViewModel = {
title: "My Space",
canInviteInSpace: true,
canAccessSpaceSettings: true,
openSpaceHome: jest.fn(),
inviteInSpace: jest.fn(),
openSpacePreferences: jest.fn(),
openSpaceSettings: jest.fn(),
};
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: true,
spaceMenuViewModel: mockSpaceMenuViewModel,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
expect(screen.getByText("My Space")).toBeInTheDocument();
// Space menu chevron button should be present
expect(screen.getByLabelText("Open space menu")).toBeInTheDocument();
});
it("renders compose menu when displayComposeMenu is true", () => {
const mockComposeMenuViewModel: ComposeMenuViewModel = {
canCreateRoom: true,
canCreateVideoRoom: true,
createChatRoom: jest.fn(),
createRoom: jest.fn(),
createVideoRoom: jest.fn(),
};
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: false,
displayComposeMenu: true,
composeMenuViewModel: mockComposeMenuViewModel,
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
// Compose button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders compose icon button when displayComposeMenu is false", () => {
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
// Compose icon button should be present
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
});
it("renders sort options menu", () => {
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
// Sort options menu trigger should be present
expect(screen.getByLabelText("Room options")).toBeInTheDocument();
});
it("truncates long titles with title attribute", () => {
const longTitle = "This is a very long space name that should be truncated";
const viewModel: RoomListHeaderViewModel = {
title: longTitle,
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
const h1 = screen.getByRole("heading", { level: 1 });
expect(h1).toHaveAttribute("title", longTitle);
expect(h1).toHaveTextContent(longTitle);
});
it("renders data-testid attribute", () => {
const viewModel: RoomListHeaderViewModel = {
title: "My Space",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: mockSortOptionsViewModel,
};
render(<RoomListHeader viewModel={viewModel} />);
expect(screen.getByTestId("room-list-header")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,79 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { IconButton } from "@vector-im/compound-web";
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { SpaceMenu, type SpaceMenuViewModel } from "./SpaceMenu";
import { ComposeMenu, type ComposeMenuViewModel } from "./ComposeMenu";
import { SortOptionsMenu, type SortOptionsMenuViewModel } from "./SortOptionsMenu";
import styles from "./RoomListHeader.module.css";
/**
* ViewModel interface for RoomListHeader
*/
export interface RoomListHeaderViewModel {
/** The title to display in the header */
title: string;
/** Whether to display the space menu (true if there is an active space) */
isSpace: boolean;
/** Space menu view model (only used if isSpace is true) */
spaceMenuViewModel?: SpaceMenuViewModel;
/** Whether to display the compose menu */
displayComposeMenu: boolean;
/** Compose menu view model (only used if displayComposeMenu is true) */
composeMenuViewModel?: ComposeMenuViewModel;
/** Callback when compose button is clicked (only used if displayComposeMenu is false) */
onComposeClick?: () => void;
/** Sort options menu view model */
sortOptionsMenuViewModel: SortOptionsMenuViewModel;
}
/**
* Props for RoomListHeader component
*/
export interface RoomListHeaderProps {
/** The view model containing header data */
viewModel: RoomListHeaderViewModel;
}
/**
* A presentational header component for the room list.
* Displays a title with optional space menu, sort options, and compose actions.
*/
export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ viewModel }): JSX.Element => {
return (
<Flex
as="header"
className={styles.roomListHeader}
aria-label={_t("room|context_menu|title")}
justify="space-between"
align="center"
data-testid="room-list-header"
>
<Flex className={styles.title} align="center" gap="var(--cpd-space-1x)">
<h1 title={viewModel.title}>{viewModel.title}</h1>
{viewModel.isSpace && viewModel.spaceMenuViewModel && (
<SpaceMenu viewModel={viewModel.spaceMenuViewModel} />
)}
</Flex>
<Flex align="center" gap="var(--cpd-space-2x)">
<SortOptionsMenu viewModel={viewModel.sortOptionsMenuViewModel} />
{viewModel.displayComposeMenu && viewModel.composeMenuViewModel ? (
<ComposeMenu viewModel={viewModel.composeMenuViewModel} />
) : (
<IconButton onClick={viewModel.onComposeClick} tooltip={_t("action|new_conversation")}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
</IconButton>
)}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,85 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, type JSX } from "react";
import { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../utils/i18n";
/**
* Sort option enum
*/
export enum SortOption {
Activity = "activity",
AToZ = "atoz",
}
/**
* ViewModel for SortOptionsMenu
*/
export interface SortOptionsMenuViewModel {
/** The currently active sort option */
activeSortOption: SortOption;
/** Change the sort order of the room-list */
sort: (option: SortOption) => void;
}
/**
* Props for SortOptionsMenu component
*/
export interface SortOptionsMenuProps {
/** The view model containing menu data and callbacks */
viewModel: SortOptionsMenuViewModel;
}
const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Element => (
<Tooltip label={_t("room_list|room_options")}>
<IconButton aria-label={_t("room_list|room_options")} {...props}>
<OverflowHorizontalIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
</Tooltip>
);
/**
* The sort options menu for the room list header.
* Displays a dropdown menu with options to sort rooms by activity or alphabetically.
*/
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ viewModel }): JSX.Element => {
const [open, setOpen] = useState(false);
const onActivitySelected = useCallback(() => {
viewModel.sort(SortOption.Activity);
}, [viewModel]);
const onAtoZSelected = useCallback(() => {
viewModel.sort(SortOption.AToZ);
}, [viewModel]);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|room_options")}
showTitle={false}
align="start"
trigger={<MenuTrigger />}
>
<MenuTitle title={_t("room_list|sort")} />
<RadioMenuItem
label={_t("room_list|sort_type|activity")}
checked={viewModel.activeSortOption === SortOption.Activity}
onSelect={onActivitySelected}
/>
<RadioMenuItem
label={_t("room_list|sort_type|atoz")}
checked={viewModel.activeSortOption === SortOption.AToZ}
onSelect={onAtoZSelected}
/>
</Menu>
);
};

View File

@ -0,0 +1,96 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, type JSX } from "react";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import { _t } from "../../utils/i18n";
/**
* ViewModel for SpaceMenu
*/
export interface SpaceMenuViewModel {
/** The title of the space */
title: string;
/** Whether the user can invite in the space */
canInviteInSpace: boolean;
/** Whether the user can access space settings */
canAccessSpaceSettings: boolean;
/** Open the space home */
openSpaceHome: () => void;
/** Display the space invite dialog */
inviteInSpace: () => void;
/** Open the space preferences */
openSpacePreferences: () => void;
/** Open the space settings */
openSpaceSettings: () => void;
}
/**
* Props for SpaceMenu component
*/
export interface SpaceMenuProps {
/** The view model containing menu data and callbacks */
viewModel: SpaceMenuViewModel;
}
/**
* The space menu for the room list header.
* Displays a dropdown menu with space-specific actions.
*/
export const SpaceMenu: React.FC<SpaceMenuProps> = ({ viewModel }): JSX.Element => {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={viewModel.title}
side="right"
align="start"
trigger={
<IconButton className="mx_SpaceMenu_button" aria-label={_t("room_list|open_space_menu")} size="20px">
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
}
>
<MenuItem
Icon={HomeIcon}
label={_t("room_list|space_menu|home")}
onSelect={viewModel.openSpaceHome}
hideChevron={true}
/>
{viewModel.canInviteInSpace && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={viewModel.inviteInSpace}
hideChevron={true}
/>
)}
<MenuItem
Icon={PreferencesIcon}
label={_t("common|preferences")}
onSelect={viewModel.openSpacePreferences}
hideChevron={true}
/>
{viewModel.canAccessSpaceSettings && (
<MenuItem
Icon={SettingsIcon}
label={_t("room_list|space_menu|space_settings")}
onSelect={viewModel.openSpaceSettings}
hideChevron={true}
/>
)}
</Menu>
);
};

View File

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListHeader } from "./RoomListHeader";
export type { RoomListHeaderProps, RoomListHeaderViewModel } from "./RoomListHeader";
export { SpaceMenu } from "./SpaceMenu";
export type { SpaceMenuProps, SpaceMenuViewModel } from "./SpaceMenu";
export { ComposeMenu } from "./ComposeMenu";
export type { ComposeMenuProps, ComposeMenuViewModel } from "./ComposeMenu";
export { SortOptionsMenu, SortOption } from "./SortOptionsMenu";
export type { SortOptionsMenuProps, SortOptionsMenuViewModel } from "./SortOptionsMenu";

View File

@ -0,0 +1,81 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
/**
* The RoomListItem has the following structure:
* button--------------------------------------------------|
* | <-12px-> container------------------------------------|
* | | room avatar <-8px-> content----------------|
* | | | room_name <- 20px ->|
* | | | --------------------| <-- border
* |-------------------------------------------------------|
*/
.roomListItem {
/* Remove button default style */
background: unset;
border: none;
padding: 0;
text-align: unset;
cursor: pointer;
height: 48px;
width: 100%;
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-primary);
}
.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);
}
.text {
min-width: 0;
}
.roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hover {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.menuOpen .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
* We need to use 18px to align the icon with the others icons
* 18px is not available in compound spacing
*/
padding-right: 18px;
}
.selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}
.bold .roomName {
font: var(--cpd-font-body-md-semibold);
}

View File

@ -0,0 +1,226 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import type { RoomNotifState } from "../../notifications/RoomNotifs";
import type { Meta, StoryObj } from "@storybook/react-vite";
// Mock avatar component
const mockAvatar = (
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: "#0dbd8b",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
}}
>
TR
</div>
);
// Mock notification view model with notifications
const mockNotificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: true,
count: 3,
muted: false,
};
// Mock notification view model without notifications
const mockEmptyNotificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
// Mock menu view model
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log("Mark as read"),
markAsUnread: () => console.log("Mark as unread"),
toggleFavorite: () => console.log("Toggle favorite"),
toggleLowPriority: () => console.log("Toggle low priority"),
invite: () => console.log("Invite"),
copyRoomLink: () => console.log("Copy room link"),
leaveRoom: () => console.log("Leave room"),
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
};
const baseViewModel: RoomListItemViewModel = {
id: "!test:example.org",
name: "Test Room",
openRoom: () => console.log("Opening room"),
a11yLabel: "Test Room, no unread messages",
isBold: false,
messagePreview: undefined,
notificationViewModel: mockEmptyNotificationViewModel,
menuViewModel: mockMenuViewModel,
};
const meta = {
title: "Room List/RoomListItem",
component: RoomListItem,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", border: "1px solid #ccc" }}>
<Story />
</div>
),
],
args: {
viewModel: baseViewModel,
isSelected: false,
isFocused: false,
onFocus: () => {},
roomIndex: 0,
roomCount: 10,
avatar: mockAvatar,
},
} satisfies Meta<typeof RoomListItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithMessagePreview: Story = {
args: {
viewModel: {
...baseViewModel,
messagePreview: "Alice: Hey, are you coming to the meeting?",
},
},
};
export const WithUnread: Story = {
args: {
viewModel: {
...baseViewModel,
name: "Team Chat",
isBold: true,
a11yLabel: "Team Chat, 3 unread messages",
notificationViewModel: mockNotificationViewModel,
},
},
};
export const Selected: Story = {
args: {
isSelected: true,
},
};
export const Focused: Story = {
args: {
isFocused: true,
},
};
export const LongRoomName: Story = {
args: {
viewModel: {
...baseViewModel,
name: "This is a very long room name that should be truncated with ellipsis when it exceeds the available width",
messagePreview: "And this is also a very long message preview that should also be truncated",
},
},
};
export const BoldWithPreview: Story = {
args: {
viewModel: {
...baseViewModel,
name: "Design Team",
isBold: true,
messagePreview: "Bob shared a new design file",
notificationViewModel: mockNotificationViewModel,
},
},
};
export const AllStates: Story = {
render: (): React.ReactElement => (
<div style={{ width: "320px" }}>
<RoomListItem
viewModel={baseViewModel}
isSelected={false}
isFocused={false}
onFocus={() => {}}
roomIndex={0}
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={{ ...baseViewModel, isBold: true, notificationViewModel: mockNotificationViewModel }}
isSelected={false}
isFocused={false}
onFocus={() => {}}
roomIndex={1}
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={baseViewModel}
isSelected={true}
isFocused={false}
onFocus={() => {}}
roomIndex={2}
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={{ ...baseViewModel, messagePreview: "Latest message" }}
isSelected={false}
isFocused={false}
onFocus={() => {}}
roomIndex={3}
roomCount={5}
avatar={mockAvatar}
/>
<RoomListItem
viewModel={baseViewModel}
isSelected={false}
isFocused={true}
onFocus={() => {}}
roomIndex={4}
roomCount={5}
avatar={mockAvatar}
/>
</div>
),
};

View File

@ -0,0 +1,221 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
describe("RoomListItem", () => {
const mockNotificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: false,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
count: 0,
muted: false,
};
const mockMenuViewModel: RoomListItemMenuViewModel = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: true,
canMarkAsUnread: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: jest.fn(),
markAsUnread: jest.fn(),
toggleFavorite: jest.fn(),
toggleLowPriority: jest.fn(),
invite: jest.fn(),
copyRoomLink: jest.fn(),
leaveRoom: jest.fn(),
setRoomNotifState: jest.fn(),
};
const mockViewModel: RoomListItemViewModel = {
id: "!test:example.org",
name: "Test Room",
openRoom: jest.fn(),
a11yLabel: "Test Room, no unread messages",
isBold: false,
messagePreview: undefined,
notificationViewModel: mockNotificationViewModel,
menuViewModel: mockMenuViewModel,
};
const mockAvatar = <div data-testid="mock-avatar">Avatar</div>;
beforeEach(() => {
jest.clearAllMocks();
});
it("renders room name and avatar", () => {
render(
<RoomListItem
viewModel={mockViewModel}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
expect(screen.getByText("Test Room")).toBeInTheDocument();
expect(screen.getByTestId("mock-avatar")).toBeInTheDocument();
});
it("renders with message preview", () => {
const vmWithPreview = { ...mockViewModel, messagePreview: "Latest message preview" };
render(
<RoomListItem
viewModel={vmWithPreview}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
expect(screen.getByText("Latest message preview")).toBeInTheDocument();
});
it("applies selected styles when selected", () => {
render(
<RoomListItem
viewModel={mockViewModel}
isSelected={true}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
const button = screen.getByRole("option");
expect(button).toHaveAttribute("aria-selected", "true");
});
it("applies bold styles when room has unread", () => {
const vmWithUnread = { ...mockViewModel, isBold: true };
render(
<RoomListItem
viewModel={vmWithUnread}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
const button = screen.getByRole("option");
// Check that the bold class is applied
expect(button.className).toContain("bold");
});
it("calls openRoom when clicked", async () => {
const user = userEvent.setup();
render(
<RoomListItem
viewModel={mockViewModel}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
await user.click(screen.getByRole("option"));
expect(mockViewModel.openRoom).toHaveBeenCalledTimes(1);
});
it("calls onFocus when focused", async () => {
const onFocus = jest.fn();
render(
<RoomListItem
viewModel={mockViewModel}
isSelected={false}
isFocused={false}
onFocus={onFocus}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
const button = screen.getByRole("option");
button.focus();
expect(onFocus).toHaveBeenCalled();
});
it("renders notification decoration when hasAnyNotificationOrActivity is true", () => {
const notificationVM: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: true,
isNotification: false,
count: 0,
muted: false,
};
const vmWithNotification = { ...mockViewModel, notificationViewModel: notificationVM };
render(
<RoomListItem
viewModel={vmWithNotification}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={10}
avatar={mockAvatar}
/>,
);
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
});
it("sets correct ARIA attributes", () => {
render(
<RoomListItem
viewModel={mockViewModel}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={5}
roomCount={20}
avatar={mockAvatar}
/>,
);
const button = screen.getByRole("option");
expect(button).toHaveAttribute("aria-posinset", "6"); // index + 1
expect(button).toHaveAttribute("aria-setsize", "20");
expect(button).toHaveAttribute("aria-label", mockViewModel.a11yLabel);
});
});

View File

@ -0,0 +1,161 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import classNames from "classnames";
import { Flex } from "../../utils/Flex";
import {
NotificationDecoration,
type NotificationDecorationViewModel,
} from "../../notifications/NotificationDecoration";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
import styles from "./RoomListItem.module.css";
/**
* ViewModel interface for RoomListItem
* Element-web will provide implementations that connect to Matrix SDK
*/
export interface RoomListItemViewModel {
/** Unique identifier for the room (used for list keying) */
id: string;
/** The name of the room */
name: string;
/** Callback to open the room */
openRoom: () => void;
/** Accessibility label for the room list item */
a11yLabel: string;
/** Whether the room name should be bolded (has unread/activity) */
isBold: boolean;
/** Optional message preview text */
messagePreview?: string;
/** Notification decoration view model */
notificationViewModel: NotificationDecorationViewModel;
/** Menu view model (for hover and context menus) */
menuViewModel: RoomListItemMenuViewModel;
}
/**
* Props for RoomListItem component
*/
export interface RoomListItemProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/** The view model containing room data and actions */
viewModel: RoomListItemViewModel;
/** Whether the room is currently selected */
isSelected: boolean;
/** Whether the room is currently focused */
isFocused: boolean;
/** Callback when the item receives focus */
onFocus: (e: React.FocusEvent) => void;
/** The index of the room in the list (for accessibility) */
roomIndex: number;
/** The total number of rooms in the list (for accessibility) */
roomCount: number;
/** Custom avatar component to render */
avatar: ReactNode;
}
/**
* A presentational room list item component.
* Displays room name, avatar, message preview, and notifications.
* Delegates all business logic to the viewModel and render functions.
*/
export const RoomListItem = memo(function RoomListItem({
viewModel,
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
avatar,
...props
}: RoomListItemProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const [isHover, setHover] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu 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 || isFocused || isHover;
const showHoverMenu = showHoverDecoration;
const closeMenu = useCallback(() => {
// To avoid icon blinking when closing the menu, we delay the state update
// Also, let the focus move to the menu trigger before closing the menu
setTimeout(() => setIsMenuOpen(false), 10);
}, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true });
}
}, [isFocused]);
const content = (
<Flex
as="button"
ref={ref}
className={classNames(styles.roomListItem, {
[styles.hover]: showHoverDecoration,
[styles.menuOpen]: showHoverMenu,
[styles.selected]: isSelected,
[styles.bold]: viewModel.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={roomIndex + 1}
aria-setsize={roomCount}
aria-selected={isSelected}
aria-label={viewModel.a11yLabel}
onClick={() => viewModel.openRoom()}
onFocus={onFocus}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onBlur={() => setHover(false)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
{avatar}
<Flex className={styles.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={styles.text}>
<div className={styles.roomName} title={viewModel.name}>
{viewModel.name}
</div>
{viewModel.messagePreview && (
<div className={styles.messagePreview} title={viewModel.messagePreview}>
{viewModel.messagePreview}
</div>
)}
</div>
{showHoverMenu ? (
<RoomListItemHoverMenu
viewModel={viewModel.menuViewModel}
onMenuOpenChange={(isOpen: boolean) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div aria-hidden={true}>
<NotificationDecoration viewModel={viewModel.notificationViewModel} />
</div>
</>
)}
</Flex>
</Flex>
);
return (
<RoomListItemContextMenu viewModel={viewModel.menuViewModel} onMenuOpenChange={setIsMenuOpen}>
{content}
</RoomListItemContextMenu>
);
});

View File

@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type PropsWithChildren } from "react";
import { ContextMenu } from "@vector-im/compound-web";
import { _t } from "../../utils/i18n";
import { MoreOptionContent } from "./RoomListItemHoverMenu";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
/**
* Props for RoomListItemContextMenu component
*/
export interface RoomListItemContextMenuProps {
/** The view model containing menu data and callbacks */
viewModel: RoomListItemMenuViewModel;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
/**
* The context menu for room list items.
* Wraps the trigger element with a right-click context menu displaying room options.
*/
export const RoomListItemContextMenu: React.FC<PropsWithChildren<RoomListItemContextMenuProps>> = ({
viewModel,
onMenuOpenChange,
children,
}): JSX.Element => {
return (
<ContextMenu
title={_t("room_list|room|more_options")}
showTitle={false}
hasAccessibleAlternative={true}
trigger={children}
onOpenChange={onMenuOpenChange}
>
<MoreOptionContent viewModel={viewModel} />
</ContextMenu>
);
};

View File

@ -0,0 +1,253 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback, type JSX, type ComponentProps } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { RoomNotifState } from "../../notifications/RoomNotifs";
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
/**
* Props for RoomListItemHoverMenu component
*/
export interface RoomListItemHoverMenuProps {
/** The view model containing menu data and callbacks */
viewModel: RoomListItemMenuViewModel;
/** Callback when menu open state changes */
onMenuOpenChange: (isOpen: boolean) => void;
}
/**
* The hover menu for room list items.
* Displays more options and notification settings menus.
*/
export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
viewModel,
onMenuOpenChange,
}): JSX.Element => {
return (
<Flex className="mx_RoomListItemHoverMenu" align="center" gap="var(--cpd-space-1x)">
{viewModel.showMoreOptionsMenu && (
<MoreOptionsMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
)}
{viewModel.showNotificationMenu && (
<NotificationMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
)}
</Flex>
);
};
interface MoreOptionsMenuProps {
viewModel: RoomListItemMenuViewModel;
onMenuOpenChange: (isOpen: boolean) => void;
}
function MoreOptionsMenu({ viewModel, onMenuOpenChange }: MoreOptionsMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
return (
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|room|more_options")}
showTitle={false}
align="start"
trigger={<MoreOptionsButton size="24px" />}
>
<MoreOptionContent viewModel={viewModel} />
</Menu>
);
}
interface MoreOptionContentProps {
viewModel: RoomListItemMenuViewModel;
}
export function MoreOptionContent({ viewModel }: MoreOptionContentProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
{viewModel.canMarkAsRead && (
<MenuItem
Icon={MarkAsReadIcon}
label={_t("room_list|more_options|mark_read")}
onSelect={viewModel.markAsRead}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{viewModel.canMarkAsUnread && (
<MenuItem
Icon={MarkAsUnreadIcon}
label={_t("room_list|more_options|mark_unread")}
onSelect={viewModel.markAsUnread}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<ToggleMenuItem
checked={viewModel.isFavourite}
Icon={FavouriteIcon}
label={_t("room_list|more_options|favourited")}
onSelect={viewModel.toggleFavorite}
onClick={(evt) => evt.stopPropagation()}
/>
<ToggleMenuItem
checked={viewModel.isLowPriority}
Icon={ArrowDownIcon}
label={_t("room_list|more_options|low_priority")}
onSelect={viewModel.toggleLowPriority}
onClick={(evt) => evt.stopPropagation()}
/>
{viewModel.canInvite && (
<MenuItem
Icon={UserAddIcon}
label={_t("action|invite")}
onSelect={viewModel.invite}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
{viewModel.canCopyRoomLink && (
<MenuItem
Icon={LinkIcon}
label={_t("room_list|more_options|copy_link")}
onSelect={viewModel.copyRoomLink}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
)}
<Separator />
<MenuItem
kind="critical"
Icon={LeaveIcon}
label={_t("room_list|more_options|leave_room")}
onSelect={viewModel.leaveRoom}
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
);
}
const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps<typeof IconButton>): JSX.Element {
return (
<Tooltip label={_t("room_list|room|more_options")}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
<OverflowIcon />
</IconButton>
</Tooltip>
);
};
interface NotificationMenuProps {
viewModel: RoomListItemMenuViewModel;
onMenuOpenChange: (isOpen: boolean) => void;
}
function NotificationMenu({ viewModel, onMenuOpenChange }: NotificationMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
onMenuOpenChange(isOpen);
},
[onMenuOpenChange],
);
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onKeyDown={(e) => e.stopPropagation()}>
<Menu
open={open}
onOpenChange={handleOpenChange}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={viewModel.isNotificationMute} size="24px" />}
>
<MenuItem
aria-selected={viewModel.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={viewModel.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{viewModel.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
);
}
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
isRoomMuted: boolean;
}
const NotificationButton = function NotificationButton({
isRoomMuted,
...props
}: NotificationButtonProps): JSX.Element {
return (
<Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props}>
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
</IconButton>
</Tooltip>
);
};

View File

@ -0,0 +1,55 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type RoomNotifState } from "../../notifications/RoomNotifs";
/**
* ViewModel interface for room list item menus (hover menu and context menu).
* Contains all the data and callbacks needed to render the menu options.
*/
export interface RoomListItemMenuViewModel {
/** Whether the more options menu should be shown */
showMoreOptionsMenu: boolean;
/** Whether the notification menu should be shown */
showNotificationMenu: boolean;
/** Whether the room is a favourite room */
isFavourite: boolean;
/** Whether the room is a low priority room */
isLowPriority: boolean;
/** Can invite other users in the room */
canInvite: boolean;
/** Can copy the room link */
canCopyRoomLink: boolean;
/** Can mark the room as read */
canMarkAsRead: boolean;
/** Can mark the room as unread */
canMarkAsUnread: boolean;
/** Whether the notification is set to all messages */
isNotificationAllMessage: boolean;
/** Whether the notification is set to all messages loud */
isNotificationAllMessageLoud: boolean;
/** Whether the notification is set to mentions and keywords only */
isNotificationMentionOnly: boolean;
/** Whether the notification is muted */
isNotificationMute: boolean;
/** Mark the room as read */
markAsRead: () => void;
/** Mark the room as unread */
markAsUnread: () => void;
/** Toggle the room as favourite */
toggleFavorite: () => void;
/** Toggle the room as low priority */
toggleLowPriority: () => void;
/** Invite other users in the room */
invite: () => void;
/** Copy the room link to clipboard */
copyRoomLink: () => void;
/** Leave the room */
leaveRoom: () => void;
/** Set the room notification state */
setRoomNotifState: (state: RoomNotifState) => void;
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListItem } from "./RoomListItem";
export type { RoomListItemProps, RoomListItemViewModel } from "./RoomListItem";

View File

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.roomListPanel {
background-color: var(--cpd-color-bg-canvas-default);
height: 100%;
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}

View File

@ -0,0 +1,218 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
import { type RoomsResult } from "../RoomList";
import { type RoomListItemViewModel } from "../RoomListItem";
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
import { type FilterViewModel } from "../RoomListPrimaryFilters/useVisibleFilters";
// Mock avatar component
const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => (
<div
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: "#0dbd8b",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: "12px",
}}
>
{roomViewModel.name.substring(0, 2).toUpperCase()}
</div>
);
// Generate mock rooms
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
return Array.from({ length: count }, (_, i) => {
const unreadCount = Math.random() > 0.7 ? Math.floor(Math.random() * 10) : 0;
const hasNotification = Math.random() > 0.8;
const notificationViewModel: NotificationDecorationViewModel = {
hasAnyNotificationOrActivity: unreadCount > 0,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: unreadCount > 0 && !hasNotification,
isNotification: hasNotification,
count: unreadCount,
muted: false,
};
return {
id: `!room${i}:server`,
name: `Room ${i + 1}`,
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
a11yLabel: unreadCount ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
isBold: unreadCount > 0,
messagePreview: undefined,
notificationViewModel,
menuViewModel: {
showMoreOptionsMenu: true,
showNotificationMenu: true,
canMarkAsRead: unreadCount > 0,
canMarkAsUnread: unreadCount === 0,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
isNotificationAllMessage: true,
isNotificationAllMessageLoud: false,
isNotificationMentionOnly: false,
isNotificationMute: false,
markAsRead: () => console.log(`Mark read: Room ${i + 1}`),
markAsUnread: () => console.log(`Mark unread: Room ${i + 1}`),
toggleFavorite: () => console.log(`Toggle favorite: Room ${i + 1}`),
toggleLowPriority: () => console.log(`Toggle low priority: Room ${i + 1}`),
invite: () => console.log(`Invite: Room ${i + 1}`),
copyRoomLink: () => console.log(`Copy link: Room ${i + 1}`),
leaveRoom: () => console.log(`Leave: Room ${i + 1}`),
setRoomNotifState: (state) => console.log(`Set notif state: ${state}`),
},
};
});
};
const mockRoomsResult: RoomsResult = {
spaceId: "!space:server",
filterKeys: undefined,
rooms: generateMockRooms(20),
};
// Create mock filters
const createFilters = (): FilterViewModel[] => {
const filters = ["All", "People", "Rooms", "Favourites", "Unread"];
return filters.map((name, index) => ({
name,
active: index === 0,
toggle: () => console.log(`Filter: ${name}`),
}));
};
const meta: Meta<typeof RoomListPanel> = {
title: "Room List/RoomListPanel",
component: RoomListPanel,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof RoomListPanel>;
const baseViewModel: RoomListPanelViewModel = {
ariaLabel: "Room list navigation",
searchViewModel: {
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
headerViewModel: {
title: "Home",
isSpace: false,
displayComposeMenu: false,
onComposeClick: () => console.log("Compose"),
sortOptionsMenuViewModel: {
activeSortOption: SortOption.Activity,
sort: (option) => console.log(`Sort: ${option}`),
},
},
viewViewModel: {
isLoadingRooms: false,
isRoomListEmpty: false,
filtersViewModel: {
filters: createFilters(),
},
roomListViewModel: {
roomsResult: mockRoomsResult,
activeRoomIndex: 0,
},
emptyStateTitle: "No rooms",
emptyStateDescription: "Join a room to get started",
},
};
export const Default: Story = {
args: {
viewModel: baseViewModel,
renderAvatar: mockAvatar,
},
decorators: [
(Story) => (
<div style={{ height: "600px", width: "320px" }}>
<Story />
</div>
),
],
};
export const WithoutSearch: Story = {
args: {
viewModel: {
...baseViewModel,
searchViewModel: undefined,
},
renderAvatar: mockAvatar,
},
decorators: [
(Story) => (
<div style={{ height: "600px", width: "320px" }}>
<Story />
</div>
),
],
};
export const Loading: Story = {
args: {
viewModel: {
...baseViewModel,
viewViewModel: {
...baseViewModel.viewViewModel,
isLoadingRooms: true,
},
},
renderAvatar: mockAvatar,
},
decorators: [
(Story) => (
<div style={{ height: "600px", width: "320px" }}>
<Story />
</div>
),
],
};
export const Empty: Story = {
args: {
viewModel: {
...baseViewModel,
viewViewModel: {
...baseViewModel.viewViewModel,
isRoomListEmpty: true,
emptyStateTitle: "No rooms to display",
emptyStateDescription: "Join a room or start a conversation to get started",
},
},
renderAvatar: mockAvatar,
},
decorators: [
(Story) => (
<div style={{ height: "600px", width: "320px" }}>
<Story />
</div>
),
],
};

View File

@ -0,0 +1,111 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "jest-matrix-react";
import React from "react";
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
import { SortOption } from "../RoomListHeader";
import type { RoomListItemViewModel } from "../RoomListItem";
describe("RoomListPanel", () => {
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
));
const mockViewModel: RoomListPanelViewModel = {
ariaLabel: "Room List",
searchViewModel: {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
},
headerViewModel: {
title: "Test Header",
isSpace: false,
displayComposeMenu: false,
onComposeClick: jest.fn(),
sortOptionsMenuViewModel: {
activeSortOption: SortOption.Activity,
sort: jest.fn(),
},
},
viewViewModel: {
isLoadingRooms: false,
isRoomListEmpty: false,
emptyStateTitle: "No rooms",
filtersViewModel: {
filters: [],
},
roomListViewModel: {
roomsResult: {
spaceId: "!space:server",
filterKeys: undefined,
rooms: [],
},
activeRoomIndex: undefined,
onKeyDown: undefined,
},
},
};
it("renders with search, header, and content", () => {
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
expect(screen.getByText("Test Header")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument();
});
it("renders without search", () => {
const vmWithoutSearch = {
...mockViewModel,
searchViewModel: undefined,
};
render(<RoomListPanel viewModel={vmWithoutSearch} renderAvatar={mockRenderAvatar} />);
expect(screen.getByText("Test Header")).toBeInTheDocument();
});
it("renders loading state", () => {
const vmLoading: RoomListPanelViewModel = {
...mockViewModel,
viewViewModel: {
...mockViewModel.viewViewModel,
isLoadingRooms: true,
isRoomListEmpty: false,
},
};
render(<RoomListPanel viewModel={vmLoading} renderAvatar={mockRenderAvatar} />);
// RoomListPanel should render (loading state is handled by RoomListView)
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
it("renders empty state", () => {
const vmEmpty: RoomListPanelViewModel = {
...mockViewModel,
viewViewModel: {
...mockViewModel.viewViewModel,
isLoadingRooms: false,
isRoomListEmpty: true,
},
};
render(<RoomListPanel viewModel={vmEmpty} renderAvatar={mockRenderAvatar} />);
// RoomListPanel should render (empty state is handled by RoomListView)
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
it("passes additional HTML attributes", () => {
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} data-testid="custom-panel" />);
expect(screen.getByTestId("custom-panel")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,60 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { Flex } from "../../utils/Flex";
import { RoomListSearch, type RoomListSearchViewModel } from "../RoomListSearch";
import { RoomListHeader, type RoomListHeaderViewModel } from "../RoomListHeader";
import { RoomListView, type RoomListViewViewModel } from "../RoomListView";
import { type RoomListItemViewModel } from "../RoomListItem";
import styles from "./RoomListPanel.module.css";
/**
* ViewModel interface for RoomListPanel
*/
export interface RoomListPanelViewModel {
/** Accessibility label for the navigation landmark */
ariaLabel: string;
/** Optional search view model */
searchViewModel?: RoomListSearchViewModel;
/** Header view model */
headerViewModel: RoomListHeaderViewModel;
/** View model for the main content area */
viewViewModel: RoomListViewViewModel;
}
/**
* Props for RoomListPanel component
*/
export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
/** The view model containing all data and callbacks */
viewModel: RoomListPanelViewModel;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
}
/**
* A complete room list panel component.
* Composes search, header, and content areas with a ViewModel pattern.
*/
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ viewModel, renderAvatar, ...props }): JSX.Element => {
return (
<Flex
as="nav"
className={styles.roomListPanel}
direction="column"
align="stretch"
aria-label={viewModel.ariaLabel}
{...props}
>
{viewModel.searchViewModel && <RoomListSearch viewModel={viewModel.searchViewModel} />}
<RoomListHeader viewModel={viewModel.headerViewModel} />
<RoomListView viewModel={viewModel.viewViewModel} renderAvatar={renderAvatar} />
</Flex>
);
};

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListPanel } from "./RoomListPanel";
export type { RoomListPanelProps } from "./RoomListPanel";

View File

@ -0,0 +1,32 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.roomListPrimaryFilters {
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
}
.list {
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;
flex: 1;
}
/* Styles for element-web wrapping class */
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_wrapping) {
display: none;
}
/* IconButton styles for chevron */
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_IconButton) svg {
transition: transform 0.1s linear;
}
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"]) svg {
transform: rotate(180deg);
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import type { FilterViewModel } from "./useVisibleFilters";
const meta: Meta<typeof RoomListPrimaryFilters> = {
title: "Room List/RoomListPrimaryFilters",
component: RoomListPrimaryFilters,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof RoomListPrimaryFilters>;
// Mock filter data - simple presentation data only
const createFilters = (selectedIndex: number = 0): FilterViewModel[] => {
const filterNames = ["All", "People", "Rooms", "Favourites", "Unread"];
return filterNames.map((name, index) => ({
name,
active: index === selectedIndex,
toggle: () => console.log(`Filter toggled: ${name}`),
}));
};
export const Default: Story = {
args: {
viewModel: {
filters: createFilters(0),
},
},
};
export const PeopleSelected: Story = {
args: {
viewModel: {
filters: createFilters(1),
},
},
};
export const FewFilters: Story = {
args: {
viewModel: {
filters: [
{
name: "All",
active: true,
toggle: () => console.log("All toggled"),
},
{
name: "Unread",
active: false,
toggle: () => console.log("Unread toggled"),
},
],
},
},
};

View File

@ -0,0 +1,85 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useId, useState } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useCollapseFilters } from "./useCollapseFilters";
import { useVisibleFilters, type FilterViewModel } from "./useVisibleFilters";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* ViewModel interface for RoomListPrimaryFilters - contains only presentation data
*/
export interface RoomListPrimaryFiltersViewModel {
/** Array of filter data */
filters: FilterViewModel[];
}
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** The view model containing filter data */
viewModel: RoomListPrimaryFiltersViewModel;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters with expand/collapse functionality.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ viewModel }): JSX.Element => {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
const filters = useVisibleFilters(viewModel.filters, wrappingIndex);
return (
<Flex
className={styles.roomListPrimaryFilters}
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
justify="space-between"
>
{displayChevron && (
<IconButton
kind="secondary"
aria-expanded={isExpanded}
aria-controls={id}
className="mx_RoomListPrimaryFilters_IconButton"
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
size="28px"
onClick={() => setIsExpanded((expanded) => !expanded)}
>
<ChevronDownIcon />
</IconButton>
)}
<Flex
id={id}
as="div"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
className={styles.list}
ref={ref}
>
{filters.map((filter, i) => (
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => filter.toggle()}>
{filter.name}
</ChatFilter>
))}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,70 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode, type RefObject } from "react";
import { Flex } from "../../utils/Flex";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* ViewModel interface for RoomListPrimaryFilters
*/
export interface RoomListPrimaryFiltersViewModel {
/** Unique ID for accessibility */
id: string;
/** Whether to display the chevron button */
displayChevron: boolean;
/** The chevron button component */
chevronButton?: ReactNode;
/** The filter elements to display */
filterElements: ReactNode[];
/** Accessibility label for the filter list */
filtersAriaLabel: string;
/** Ref to attach to the filter list container */
filtersRef?: RefObject<HTMLDivElement>;
}
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** The view model containing filter data */
viewModel: RoomListPrimaryFiltersViewModel;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ viewModel }): JSX.Element => {
return (
<Flex
className={styles.roomListPrimaryFilters}
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
justify="space-between"
>
{viewModel.displayChevron && viewModel.chevronButton}
<Flex
id={viewModel.id}
as="div"
role="listbox"
aria-label={viewModel.filtersAriaLabel}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
className={styles.list}
ref={viewModel.filtersRef}
>
{viewModel.filterElements.map((element, i) => (
<React.Fragment key={i}>{element}</React.Fragment>
))}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersViewModel } from "./RoomListPrimaryFilters";
export { useCollapseFilters } from "./useCollapseFilters";
export { useVisibleFilters } from "./useVisibleFilters";
export type { FilterViewModel } from "./useVisibleFilters";

View File

@ -0,0 +1,70 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* A hook to manage the wrapping of filters in the room list.
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
* @param isExpanded
* @returns an object containing:
* - `ref`: a ref to put on the filter list element
* - `isWrapping`: a boolean indicating if the filters are wrapping
* - `wrappingIndex`: the index of the first filter that is wrapping
*/
export function useCollapseFilters<T extends HTMLElement>(
isExpanded: boolean,
): {
ref: RefObject<T | null>;
isWrapping: boolean;
wrappingIndex: number;
} {
const ref = useRef<T>(null);
const [isWrapping, setIsWrapping] = useState(false);
const [wrappingIndex, setWrappingIndex] = useState(-1);
useEffect(() => {
if (!ref.current) return;
const hideFilters = (list: Element): void => {
let isWrapping = false;
Array.from(list.children).forEach((node, i): void => {
const child = node as HTMLElement;
const wrappingClass = "mx_RoomListPrimaryFilters_wrapping";
child.setAttribute("aria-hidden", "false");
child.classList.remove(wrappingClass);
// If the filter list is expanded, all filters are visible
if (isExpanded) return;
// If the previous element is on the left element of the current one, it means that the filter is wrapping
const previousSibling = child.previousElementSibling as HTMLElement | null;
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
if (!isWrapping) setWrappingIndex(i);
isWrapping = true;
}
// If the filter is wrapping, we hide it
child.classList.toggle(wrappingClass, isWrapping);
child.setAttribute("aria-hidden", isWrapping.toString());
});
if (!isWrapping) setWrappingIndex(-1);
setIsWrapping(isExpanded || isWrapping);
};
hideFilters(ref.current);
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [isExpanded]);
return { ref, isWrapping, wrappingIndex };
}

View File

@ -0,0 +1,52 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { useEffect, useState } from "react";
export interface FilterViewModel {
/** Filter name/label */
name: string;
/** Whether the filter is currently active */
active: boolean;
/** Callback when filter is clicked */
toggle: () => void;
}
/**
* A hook to sort the filters by active state.
* The list is sorted if the current filter index is greater than or equal to the wrapping index.
* If the wrapping index is -1, the filters are not sorted.
*
* @param filters - the list of filters to sort.
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(filters: FilterViewModel[], wrappingIndex: number): FilterViewModel[] {
// By default, the filters are not sorted
const [sortedFilters, setSortedFilters] = useState(filters);
useEffect(() => {
const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex;
// If the active filter is not wrapping, we don't need to sort the filters
if (!isActiveFilterWrapping || wrappingIndex === -1) {
setSortedFilters(filters);
return;
}
// Sort the filters with the current filter at first position
setSortedFilters(
filters.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA.active && !filterB.active) return -1;
if (!filterA.active && filterB.active) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
);
}, [filters, wrappingIndex]);
return sortedFilters;
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.roomListSearch {
/* From figma, this should be aligned with the room header */
flex: 0 0 64px;
box-sizing: border-box;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
padding: 0 var(--cpd-space-3x);
}
/* Styles for the search button when used in element-web */
.roomListSearch :global(.mx_RoomListSearch_search) {
/* The search button should take all the remaining space */
flex: 1;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
min-width: 0;
}
.roomListSearch :global(.mx_RoomListSearch_search) svg {
fill: var(--cpd-color-icon-secondary);
}
.roomListSearch :global(.mx_RoomListSearch_search) span {
flex: 1;
}
.roomListSearch :global(.mx_RoomListSearch_search) kbd {
font-family: inherit;
}
/* Shrink and truncate the search text */
.roomListSearch :global(.mx_RoomListSearch_search_text) {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListSearch } from "./RoomListSearch";
const meta: Meta<typeof RoomListSearch> = {
title: "Room List/RoomListSearch",
component: RoomListSearch,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof RoomListSearch>;
export const Default: Story = {
args: {
viewModel: {
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: false,
},
},
};
export const WithDialPad: Story = {
args: {
viewModel: {
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: false,
},
},
};
export const WithExplore: Story = {
args: {
viewModel: {
onSearchClick: () => console.log("Open search"),
showDialPad: false,
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
},
};
export const WithAllActions: Story = {
args: {
viewModel: {
onSearchClick: () => console.log("Open search"),
showDialPad: true,
onDialPadClick: () => console.log("Open dial pad"),
showExplore: true,
onExploreClick: () => console.log("Explore rooms"),
},
},
};

View File

@ -0,0 +1,132 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { render, screen } from "jest-matrix-react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { RoomListSearch, type RoomListSearchViewModel } from "./RoomListSearch";
describe("RoomListSearch", () => {
it("renders search button with shortcut", () => {
const onSearchClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick,
showDialPad: false,
showExplore: false,
};
render(<RoomListSearch viewModel={viewModel} />);
expect(screen.getByRole("search")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
// Keyboard shortcut should be visible
expect(screen.getByText(/K/)).toBeInTheDocument();
});
it("calls onSearchClick when search button is clicked", async () => {
const onSearchClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick,
showDialPad: false,
showExplore: false,
};
render(<RoomListSearch viewModel={viewModel} />);
await userEvent.click(screen.getByRole("button", { name: /search/i }));
expect(onSearchClick).toHaveBeenCalledTimes(1);
});
it("renders dial pad button when showDialPad is true", () => {
const onDialPadClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
};
render(<RoomListSearch viewModel={viewModel} />);
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
});
it("calls onDialPadClick when dial pad button is clicked", async () => {
const onDialPadClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick,
showExplore: false,
};
render(<RoomListSearch viewModel={viewModel} />);
await userEvent.click(screen.getByRole("button", { name: /dial pad/i }));
expect(onDialPadClick).toHaveBeenCalledTimes(1);
});
it("renders explore button when showExplore is true", () => {
const onExploreClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
};
render(<RoomListSearch viewModel={viewModel} />);
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
});
it("calls onExploreClick when explore button is clicked", async () => {
const onExploreClick = jest.fn();
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: true,
onExploreClick,
};
render(<RoomListSearch viewModel={viewModel} />);
await userEvent.click(screen.getByRole("button", { name: /explore/i }));
expect(onExploreClick).toHaveBeenCalledTimes(1);
});
it("renders all buttons when showDialPad and showExplore are true", () => {
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: true,
onDialPadClick: jest.fn(),
showExplore: true,
onExploreClick: jest.fn(),
};
render(<RoomListSearch viewModel={viewModel} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
});
it("does not render dial pad or explore buttons when flags are false", () => {
const viewModel: RoomListSearchViewModel = {
onSearchClick: jest.fn(),
showDialPad: false,
showExplore: false,
};
render(<RoomListSearch viewModel={viewModel} />);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /dial pad/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /explore/i })).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,87 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { Button } from "@vector-im/compound-web";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import styles from "./RoomListSearch.module.css";
/**
* ViewModel interface for RoomListSearch
*/
export interface RoomListSearchViewModel {
/** Callback fired when search button is clicked */
onSearchClick: () => void;
/** Whether to show the dial pad button */
showDialPad: boolean;
/** Callback fired when dial pad button is clicked */
onDialPadClick?: () => void;
/** Whether to show the explore rooms button */
showExplore: boolean;
/** Callback fired when explore button is clicked */
onExploreClick?: () => void;
}
/**
* Props for RoomListSearch component
*/
export interface RoomListSearchProps {
/** The view model containing search data */
viewModel: RoomListSearchViewModel;
}
/**
* A presentational search bar component for the room list.
* Displays a search button and optional action buttons (dial pad, explore) in a horizontal layout.
*/
export const RoomListSearch: React.FC<RoomListSearchProps> = ({ viewModel }): JSX.Element => {
// Determine keyboard shortcut based on platform
const isMac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const searchShortcut = isMac ? "⌘ K" : "Ctrl K";
return (
<Flex className={styles.roomListSearch} role="search" gap="var(--cpd-space-2x)" align="center">
<Button
className="mx_RoomListSearch_search"
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={viewModel.onSearchClick}
>
<Flex as="span" justify="space-between">
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
<kbd>{searchShortcut}</kbd>
</Flex>
</Button>
{viewModel.showDialPad && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={viewModel.onDialPadClick}
/>
)}
{viewModel.showExplore && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={viewModel.onExploreClick}
/>
)}
</Flex>
);
};

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListSearch } from "./RoomListSearch";
export type { RoomListSearchProps, RoomListSearchViewModel } from "./RoomListSearch";

View File

@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import styles from "./RoomListView.module.css";
/**
* Props for RoomListEmptyState component
*/
export interface RoomListEmptyStateProps {
/** The title to display in the empty state */
title: string;
/** The description text to display */
description?: string;
/** Optional action element (e.g., a button) to display */
action?: ReactNode;
}
/**
* Empty state component for the room list.
* Displays a message when no rooms are available.
*/
export const RoomListEmptyState: React.FC<RoomListEmptyStateProps> = ({ title, description, action }): JSX.Element => {
return (
<div className={styles.emptyState}>
<h3>{title}</h3>
{description && <p>{description}</p>}
{action}
</div>
);
};

View File

@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import styles from "./RoomListView.module.css";
/**
* Loading skeleton component for the room list.
* Displays a simple loading indicator while rooms are being fetched.
*/
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
return <div className={styles.skeleton} />;
};

View File

@ -0,0 +1,29 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.skeleton {
position: relative;
margin-left: 4px;
height: 100%;
flex: 1;
}
/* Skeleton animation - note: mask-image requires SVG from element-web */
.skeleton::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
content: "";
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 96px;
}
/* Element-web provides the actual mask-image */
:global(.mx_RoomListSkeleton)::before {
mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersViewModel } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyState } from "./RoomListEmptyState";
import { RoomList, type RoomListViewModel } from "../RoomList";
import { type RoomListItemViewModel } from "../RoomListItem";
/**
* ViewModel interface for RoomListView
*/
export interface RoomListViewViewModel {
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
isRoomListEmpty: boolean;
/** View model for the primary filters */
filtersViewModel: RoomListPrimaryFiltersViewModel;
/** View model for the room list */
roomListViewModel: RoomListViewModel;
/** Title for the empty state */
emptyStateTitle: string;
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
}
/**
* Props for RoomListView component
*/
export interface RoomListViewProps {
/** The view model containing list data */
viewModel: RoomListViewViewModel;
/** Render function for room avatar */
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
}
/**
* The main room list view component.
* Manages the display of filters, loading states, empty states, and the room list.
*/
export const RoomListView: React.FC<RoomListViewProps> = ({ viewModel, renderAvatar }): JSX.Element => {
let listBody: ReactNode;
if (viewModel.isLoadingRooms) {
listBody = <RoomListLoadingSkeleton />;
} else if (viewModel.isRoomListEmpty) {
listBody = (
<RoomListEmptyState
title={viewModel.emptyStateTitle}
description={viewModel.emptyStateDescription}
action={viewModel.emptyStateAction}
/>
);
} else {
listBody = <RoomList viewModel={viewModel.roomListViewModel} renderAvatar={renderAvatar} />;
}
return (
<>
<RoomListPrimaryFilters viewModel={viewModel.filtersViewModel} />
{listBody}
</>
);
};

View File

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewViewModel } from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyState } from "./RoomListEmptyState";
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";

View File

@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import "@testing-library/jest-dom";
import fetchMock from "fetch-mock-jest";
import { setLanguage } from "../../src/utils/i18n";

View File

@ -0,0 +1,335 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
/**
* Keyboard key codes
*/
export const Key = {
ARROW_UP: "ArrowUp",
ARROW_DOWN: "ArrowDown",
HOME: "Home",
END: "End",
PAGE_UP: "PageUp",
PAGE_DOWN: "PageDown",
ENTER: "Enter",
SPACE: "Space",
} as const;
/**
* Check if a keyboard event includes modifier keys
*/
export function isModifiedKeyEvent(event: React.KeyboardEvent): boolean {
return event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
}
/**
* Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component.
*/
export type ListContext<Context> = {
/** The key of item that should have tabIndex == 0 */
tabIndexKey?: string;
/** Whether an item in the list is currently focused */
focused: boolean;
/** Additional context data passed from the parent component */
context: Context;
};
export interface IListViewProps<Item, Context>
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "data" | "itemContent" | "context"> {
/**
* The array of items to display in the virtualized list.
* Each item will be passed to getItemComponent for rendering.
*/
items: Item[];
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: ListContext<Context>,
onFocus: (item: Item, e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Optional additional context data to pass to each rendered item.
* This will be available in the ListContext passed to getItemComponent.
*/
context?: Context;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check for focusability
* @returns true if the item can be focused, false otherwise
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to get the key to use for focusing an item.
* @param item - The item to get the key for
* @return The key to use for focusing the item
*/
getItemKey: (item: Item) => string;
/**
* Callback function to handle key down events on the list container.
* ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
/** Key of the item that should have tabIndex == 0 */
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
props.items[0] ? getItemKey(props.items[0]) : undefined,
);
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
/** Whether the list is currently scrolling to an item */
const isScrollingToItem = useRef<boolean>(false);
/** Whether the list is currently focused */
const [isFocused, setIsFocused] = useState<boolean>(false);
// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
items.forEach((item, index) => {
const key = getItemKey(item);
newKeyToIndexMap.set(key, index);
});
setKeyToIndexMap(newKeyToIndexMap);
}, [items, getItemKey]);
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => {
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
setTabIndexKey(getItemKey(items[0]));
}
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
/**
* Scrolls to a specific item index and sets it as focused.
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
*/
const scrollToIndex = useCallback(
(index: number, align?: "center" | "end" | "start"): void => {
// Ensure index is within bounds
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
if (isScrollingToItem.current) {
// If already scrolling to an item drop this request. Adding further requests
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
return;
}
if (items[clampedIndex]) {
const key = getItemKey(items[clampedIndex]);
isScrollingToItem.current = true;
virtuosoHandleRef.current?.scrollIntoView({
index: clampedIndex,
align: align,
behavior: "auto",
done: () => {
setTabIndexKey(key);
isScrollingToItem.current = false;
},
});
}
},
[items, getItemKey],
);
/**
* Scrolls to an item, skipping over non-focusable items if necessary.
* This is used for keyboard navigation to ensure focus lands on valid items.
*/
const scrollToItem = useCallback(
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
const totalRows = items.length;
let nextIndex: number | undefined;
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
if (isItemFocusable(items[i])) {
nextIndex = i;
break;
}
}
if (nextIndex === undefined) {
return;
}
scrollToIndex(nextIndex, align);
},
[scrollToIndex, items, isItemFocusable],
);
/**
* Handles keyboard navigation for the list.
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
// Guard against null/undefined events and modified keys
if (!e || isModifiedKeyEvent(e)) {
onKeyDown?.(e);
return;
}
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, "start");
handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, "start");
handled = true;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
);
/**
* Callback ref for the Virtuoso scroller element.
* Stores the reference for use in focus management.
*/
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
}, []);
/**
* Focus handler passed to each item component.
* Don't declare inside getItemComponent to avoid re-creating on each render.
*/
const onFocusForGetItemComponent = useCallback(
(item: Item, e: React.FocusEvent) => {
// If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the ListViews onFocus doesn't also handle it.
const key = getItemKey(item);
setIsFocused(true);
setTabIndexKey(key);
e.stopPropagation();
},
[getItemKey],
);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: ListContext<Context>): JSX.Element =>
getItemComponent(index, item, context, onFocusForGetItemComponent),
[getItemComponent, onFocusForGetItemComponent],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
*/
const onFocus = useCallback(
(e?: React.FocusEvent): void => {
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
return;
}
setIsFocused(true);
const index = keyToIndexMap.get(tabIndexKey);
if (
index !== undefined &&
visibleRange &&
(index < visibleRange.startIndex || index > visibleRange.endIndex)
) {
scrollToIndex(index);
}
e?.stopPropagation();
e?.preventDefault();
},
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
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> = {
tabIndexKey: tabIndexKey,
focused: isFocused,
context: props.context || ({} as Context),
};
return (
<Virtuoso
// note that either the container of direct children must be focusable to be axe
// compliant, so we leave tabIndex as the default so the container can be focused
// (virtuoso wraps the children inside another couple of elements so setting it
// on those doesn't seem to work, unfortunately)
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
onFocus={onFocus}
onBlur={onBlur}
itemContent={getItemComponentInternal}
{...virtuosoProps}
/>
);
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { ListView, Key, isModifiedKeyEvent } from "./ListView";
export type { IListViewProps, ListContext } from "./ListView";

View File

@ -6025,6 +6025,11 @@ react-merge-refs@^3.0.2:
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-3.0.2.tgz#483b4e8029f89d805c4e55c8d22e9b8f77e3b58e"
integrity sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==
react-virtuoso@^4.15.0:
version "4.15.0"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.15.0.tgz#5ce64489bb1e42944822c2ca9fff4731890bafc0"
integrity sha512-yDVJqOXf3N1GlSoXj/hMhslA6EyUmgCUVzS9u8MpdZs/KIwdTtbQnttZ0yzx8ZKdKeVdd0NPJG4pTINHbAyhyw==
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0":
version "19.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"

View File

@ -5,16 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useRef, type JSX } from "react";
import React, { useCallback, useMemo, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { RoomList as SharedRoomList, type RoomsResult, type FilterKey } from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
import { type ListContext, ListView } from "../../../utils/ListView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
@ -25,81 +22,69 @@ interface RoomListProps {
*/
vm: RoomListViewState;
}
/**
* Height of a single room list item
* Room adapter that wraps Matrix Room objects with an id property for the shared component
*/
const ROOM_LIST_ITEM_HEIGHT = 48;
/**
* Amount to extend the top and bottom of the viewport by.
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space when using the mouse wheel,
* and the trackpad scrolling at a slow to moderate speed where you can still see/read the content.
* Using the trackpad to sling through a large percentage of the list quickly will still show blank space.
* We would likely need to simplify the item content to improve this case.
*/
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
interface RoomAdapter {
id: string;
room: Room;
}
/**
* A virtualized list of rooms.
* This component adapts element-web's room list to use the shared RoomList component.
*/
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const getItemComponent = useCallback(
/**
* Adapt the element-web roomsResult to the shared component's format
*/
const adaptedRoomsResult: RoomsResult<RoomAdapter> = useMemo(
() => ({
spaceId: roomsResult.spaceId,
filterKeys: roomsResult.filterKeys as FilterKey[] | undefined,
rooms: roomsResult.rooms.map((room) => ({
id: room.roomId,
room,
})),
}),
[roomsResult],
);
/**
* Render a room item using the RoomListItemView
*/
const renderItem = useCallback(
(
index: number,
item: Room,
context: ListContext<{
spaceId: string;
filterKeys: FilterKey[] | undefined;
}>,
onFocus: (item: Room, e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.roomId;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeIndex === index;
item: RoomAdapter,
isSelected: boolean,
isFocused: boolean,
tabIndex: number,
roomCount: number,
onFocus: (item: RoomAdapter, e: React.FocusEvent) => void,
): React.ReactNode => {
return (
<RoomListItemView
room={item}
key={itemKey}
room={item.room}
key={item.id}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={isRovingItem ? 0 : -1}
tabIndex={tabIndex}
roomIndex={index}
roomCount={roomCount}
onFocus={onFocus}
onFocus={(room, e) => onFocus(item, e)}
/>
);
},
[activeIndex, roomCount],
);
const getItemKey = useCallback((item: Room): string => {
return item.roomId;
}, []);
const scrollIntoViewOnChange = useCallback(
(params: {
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false | void => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: `start`,
index: activeIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeIndex],
[],
);
/**
* Handle keyboard events for landmark navigation
*/
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
@ -114,23 +99,12 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
}, []);
return (
<ListView
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomsResult.rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
<SharedRoomList
roomsResult={adaptedRoomsResult}
activeIndex={activeIndex}
renderItem={renderItem}
onKeyDown={keyDownCallback}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
ariaLabel={_t("room_list|list_title")}
/>
);
}

View File

@ -5,10 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
import React, { type JSX, memo, useCallback, type ReactNode } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Flex } from "@element-hq/web-shared-components";
import { RoomListItem as SharedRoomListItem } from "@element-hq/web-shared-components";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
@ -44,120 +43,66 @@ interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElem
}
/**
* An item in the room list
* An item in the room list.
* This component wraps the shared RoomListItem and provides element-web specific
* implementations for the avatar, notifications, and menus.
*/
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
onFocus,
roomIndex: index,
roomCount: count,
roomIndex,
roomCount,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const vm = useRoomListItemViewModel(room);
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 || isFocused || isHover;
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
const closeMenu = useCallback(() => {
// To avoid icon blinking when closing the menu, we delay the state update
// Also, let the focus move to the menu trigger before closing the menu
setTimeout(() => setIsMenuOpen(false), 10);
}, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = (
<Flex
as="button"
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_hover: showHoverDecoration,
mx_RoomListItemView_menu_open: showHoverMenu,
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={index + 1}
aria-setsize={count}
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(room, e)}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onBlur={() => setHover(false)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
<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>
)}
</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}
callType={vm.callType}
/>
)}
</>
)}
</Flex>
</Flex>
// Wrap onFocus to include the room parameter
const handleFocus = useCallback(
(e: React.FocusEvent) => {
onFocus(room, e);
},
[onFocus, room],
);
// Rendering multiple context menus can causes crashes in radix upstream,
// See https://github.com/radix-ui/primitives/issues/2717.
if (!vm.showContextMenu) return content;
// Create the avatar component
const avatar = <RoomAvatarView room={room} />;
// Create the notification decoration component
const notificationDecoration = vm.showNotificationDecoration ? (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
callType={vm.callType}
/>
) : null;
// Create the hover menu component
const hoverMenu = vm.showHoverMenu ? <RoomListItemMenuView room={room} setMenuOpen={() => {}} /> : null;
// Create the context menu wrapper function
const contextMenuWrapper = vm.showContextMenu
? (content: ReactNode) => (
<RoomListItemContextMenuView room={room} setMenuOpen={() => {}}>
{content}
</RoomListItemContextMenuView>
)
: undefined;
return (
<RoomListItemContextMenuView
room={room}
setMenuOpen={(isOpen) => {
if (isOpen) {
// To avoid icon blinking when the context menu is re-opened
setTimeout(() => setIsMenuOpen(true), 0);
} else {
closeMenu();
}
}}
>
{content}
</RoomListItemContextMenuView>
<SharedRoomListItem
viewModel={vm}
isSelected={isSelected}
isFocused={isFocused}
onFocus={handleFocus}
roomIndex={roomIndex}
roomCount={roomCount}
avatar={avatar}
notificationDecoration={notificationDecoration}
hoverMenu={hoverMenu}
contextMenuWrapper={contextMenuWrapper}
{...props}
/>
);
});

View File

@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { useState, useCallback } from "react";
import { Flex } from "@element-hq/web-shared-components";
import { RoomListPanel as SharedRoomListPanel } from "@element-hq/web-shared-components";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
@ -59,19 +59,14 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
);
return (
<Flex
as="nav"
className="mx_RoomListPanel"
direction="column"
align="stretch"
aria-label={_t("room_list|list_title")}
<SharedRoomListPanel
ariaLabel={_t("room_list|list_title")}
searchSlot={displayRoomSearch ? <RoomListSearch activeSpace={activeSpace} /> : undefined}
headerSlot={<RoomListHeaderView />}
contentSlot={<RoomListView />}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
<RoomListHeaderView />
<RoomListView />
</Flex>
/>
);
};

View File

@ -1579,15 +1579,8 @@
yaml "^2.7.0"
"@element-hq/web-shared-components@link:packages/shared-components":
version "0.0.0-test.8"
dependencies:
classnames "^2.5.1"
counterpart "^0.18.6"
lodash "^4.17.21"
matrix-web-i18n "^3.4.0"
patch-package "^8.0.1"
react-merge-refs "^3.0.2"
temporal-polyfill "^0.3.0"
version "0.0.0"
uid ""
"@emnapi/core@^1.4.3", "@emnapi/core@^1.5.0":
version "1.7.0"
@ -4020,7 +4013,7 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.1.tgz#4c5479538ec10b5508b8e982e172911c987446d8"
integrity sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==
"@typescript-eslint/types@8.48.0", "@typescript-eslint/types@^8.48.0":
"@typescript-eslint/types@8.48.0", "@typescript-eslint/types@^8.47.0", "@typescript-eslint/types@^8.48.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.48.0.tgz#f0dc5cf27217346e9b0d90556911e01d90d0f2a5"
integrity sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==
@ -4030,11 +4023,6 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
"@typescript-eslint/types@^8.47.0":
version "8.48.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.48.0.tgz#f0dc5cf27217346e9b0d90556911e01d90d0f2a5"
integrity sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==
"@typescript-eslint/typescript-estree@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz#1c146573b942ebe609c156c217ceafdc7a88e6ed"
@ -4231,6 +4219,7 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
uid ""
"@vector-im/matrix-wysiwyg@2.40.0":
version "2.40.0"
@ -11621,6 +11610,11 @@ react-virtuoso@^4.14.0:
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.14.1.tgz#78a5e796a3f9ec501499f01962ec6fc7eed77d8d"
integrity sha512-NRUF1ak8lY+Tvc6WN9cce59gU+lilzVtOozP+pm9J7iHshLGGjsiAB4rB2qlBPHjFbcXOQpT+7womNHGDUql8w==
react-virtuoso@^4.15.0:
version "4.15.0"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.15.0.tgz#5ce64489bb1e42944822c2ca9fff4731890bafc0"
integrity sha512-yDVJqOXf3N1GlSoXj/hMhslA6EyUmgCUVzS9u8MpdZs/KIwdTtbQnttZ0yzx8ZKdKeVdd0NPJG4pTINHbAyhyw==
react@^19.0.0:
version "19.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"