Add RoomList component

Add RoomList component that renders a virtualized list of room items.
Includes story mocks for testing.
This commit is contained in:
David Langley 2026-01-30 09:43:47 +00:00
parent 78fa40d7e6
commit f70180eb91
8 changed files with 1787 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -0,0 +1,14 @@
/*
* Copyright 2025 Element Creations 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
*/
.roomList {
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,87 @@
/*
* Copyright 2026 Element Creations 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomList, type RoomListViewState } from "./RoomList";
import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView";
import { useMockedViewModel } from "../../viewmodel";
import type { FilterId } from "../RoomListPrimaryFilters";
import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks";
type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: any) => React.ReactElement };
// Use first 10 room IDs for this story
const storyRoomIds = mockRoomIds.slice(0, 10);
// Wrapper component that creates a mocked ViewModel
const RoomListWrapper = ({
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
}: RoomListStoryProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
});
return (
<div style={{ height: "400px", border: "1px solid #ccc" }}>
<RoomList vm={vm} renderAvatar={renderAvatarProp} />
</div>
);
};
const mockFilterIds: FilterId[] = ["unread", "people"];
const defaultRoomListState: RoomListViewState = {
activeRoomIndex: 0,
spaceId: "!space:server",
filterKeys: undefined,
};
const meta: Meta<RoomListStoryProps> = {
title: "Room List/RoomList",
component: RoomListWrapper,
tags: ["autodocs"],
args: {
isLoadingRooms: false,
isRoomListEmpty: false,
filterIds: mockFilterIds,
activeFilterId: undefined,
roomIds: storyRoomIds,
roomListState: defaultRoomListState,
canCreateRoom: true,
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds),
updateVisibleRooms: fn(),
renderAvatar,
},
decorators: [
(Story) => (
<div style={{ width: "300px" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<RoomListStoryProps>;
export const Default: Story = {};

View File

@ -0,0 +1,67 @@
/*
* Copyright 2026 Element Creations 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 { render, screen, fireEvent } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomList.stories";
const { Default } = composeStories(stories);
const renderWithMockContext = (component: React.ReactElement): ReturnType<typeof render> => {
return render(component, {
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}>
{children}
</VirtuosoMockContext.Provider>
),
});
};
describe("<RoomList />", () => {
it("renders Default story", () => {
const { container } = renderWithMockContext(<Default />);
expect(container).toMatchSnapshot();
});
it("should render the room list listbox", () => {
renderWithMockContext(<Default />);
expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
});
it("should render room items", () => {
renderWithMockContext(<Default />);
const items = screen.getAllByRole("option");
expect(items.length).toBeGreaterThan(0);
});
it("should mark selected room with aria-selected true", () => {
renderWithMockContext(<Default />);
const items = screen.getAllByRole("option");
// The first item (index 0) should be selected based on Default story (activeRoomIndex: 0)
expect(items[0]).toHaveAttribute("aria-selected", "true");
});
it("should handle focus state correctly", () => {
renderWithMockContext(<Default />);
const listbox = screen.getByRole("listbox", { name: "Room list" });
fireEvent.focus(listbox);
const items = screen.getAllByRole("option");
// First item should have tabIndex 0 (focusable) when list is focused
expect(items[0]).toHaveAttribute("tabIndex", "0");
});
it("should call updateVisibleRooms on render", () => {
renderWithMockContext(<Default />);
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,197 @@
/*
* Copyright 2026 Element Creations 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, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { useViewModel } from "../../viewmodel";
import { _t } from "../../utils/i18n";
import { VirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
import { RoomListItemView } from "../RoomListItem";
import type { RoomListViewModel } from "../RoomListView";
/**
* Filter key type - opaque string type for filter identifiers
*/
export type FilterKey = string;
/**
* State for the room list data (nested within RoomListSnapshot)
*/
export interface RoomListViewState {
/** Optional active room index for keyboard navigation */
activeRoomIndex?: number;
/** Space ID for context tracking */
spaceId?: string;
/** Active filter keys for context tracking */
filterKeys?: FilterKey[];
}
/**
* Props for the RoomList component
*/
export interface RoomListProps {
/**
* The view model containing all room list data and callbacks
*/
vm: RoomListViewModel;
/**
* Render function for room avatar
* @param room - The opaque Room object from the client
*/
renderAvatar: (room: any) => ReactNode;
/**
* Optional callback for keyboard key down events
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/** Height of a single room list item in pixels */
const ROOM_LIST_ITEM_HEIGHT = 48;
/**
* Type for context used in ListView
*/
type Context = { spaceId: string; filterKeys: FilterKey[] | undefined };
/**
* 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 RoomListItemView components for each room.
*
* @example
* ```tsx
* <RoomList vm={roomListViewModel} renderAvatar={(room) => <Avatar room={room} />} />
* ```
*/
export function RoomList({ vm, renderAvatar, onKeyDown }: RoomListProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomListState, roomIds } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomIds.length;
/**
* Callback when the visible range changes
* Notifies the view model which rooms are visible
*/
const rangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
vm.updateVisibleRooms(range.startIndex, range.endIndex);
},
[vm],
);
/**
* Get the item component for a specific index
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponent = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
): JSX.Element => {
const isSelected = activeRoomIndex === index;
const roomItemVM = vm.getRoomItemViewModel(roomId);
// Item is focused when the list has focus AND this item's key matches tabIndexKey
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === roomId;
return (
<RoomListItemView
key={roomId}
vm={roomItemVM}
renderAvatar={renderAvatar}
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={index}
roomCount={roomCount}
/>
);
},
[activeRoomIndex, roomCount, renderAvatar, vm],
);
/**
* Get the key for a room item
* Since we're using virtualization, items are always room ID strings
*/
const getItemKey = useCallback((item: string): string => {
return item;
}, []);
const context = useMemo(
() => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }),
[roomListState.spaceId, roomListState.filterKeys],
);
/**
* Determine if we should scroll the active index into view
* This happens when the space or filters change
*/
const scrollIntoViewOnChange = useCallback(
(params: {
context: VirtualizedListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false => {
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],
);
return (
<VirtualizedList
context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeRoomIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
items={roomIds}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
rangeChanged={rangeChanged}
onKeyDown={onKeyDown}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
/>
);
}

View File

@ -0,0 +1,9 @@
/*
* Copyright 2026 Element Creations 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, RoomListViewState, FilterKey } from "./RoomList";

View File

@ -0,0 +1,136 @@
/*
* Copyright 2026 Element Creations 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 { fn } from "storybook/test";
import type { RoomListItemSnapshot } from "./RoomListItem";
import { RoomNotifState } from "./RoomListItem/RoomNotifs";
/**
* Mock avatar component for stories
*/
export const mockAvatar = (name: string): React.ReactElement => (
<div
role="img"
aria-label={`${name} avatar`}
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: "#0B7F67",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
fontSize: "12px",
}}
>
{name.substring(0, 2).toUpperCase()}
</div>
);
/**
* Render avatar function for stories
*/
export const renderAvatar = (room: any): React.ReactElement => {
return mockAvatar(room?.name || "Room");
};
/**
* Room names used for mock data
*/
const roomNames = [
"General",
"Random",
"Engineering",
"Design",
"Product",
"Marketing",
"Sales",
"Support",
"Announcements",
"Off-topic",
"Team Alpha",
"Team Beta",
"Project X",
"Project Y",
"Water Cooler",
"Feedback",
"Ideas",
"Bugs",
"Features",
"Releases",
];
/**
* Create a mock room item snapshot for stories
*/
export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({
id,
room: { name },
name,
isBold: index % 3 === 0,
messagePreview: index % 2 === 0 ? `Last message in ${name}` : undefined,
notification: {
hasAnyNotificationOrActivity: index % 5 === 0,
isUnsentMessage: false,
invited: false,
isMention: index % 5 === 0,
isActivityNotification: false,
isNotification: index % 5 === 0,
hasUnreadCount: index % 5 === 0,
count: index % 5 === 0 ? index : 0,
muted: false,
},
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: false,
isLowPriority: false,
canInvite: true,
canCopyRoomLink: true,
canMarkAsRead: false,
canMarkAsUnread: true,
roomNotifState: RoomNotifState.AllMessages,
});
/**
* Create a mock getRoomItemViewModel function for stories
*/
export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => any) => {
const viewModels = new Map();
roomIds.forEach((roomId, index) => {
const name = roomNames[index % roomNames.length];
const snapshot = createMockRoomSnapshot(roomId, name, index);
const mockViewModel = {
getSnapshot: () => snapshot,
subscribe: fn(),
unsubscribe: fn(),
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
};
viewModels.set(roomId, mockViewModel);
});
return (roomId: string) => viewModels.get(roomId);
};
/**
* Mock room IDs for different list sizes
*/
export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`);
export const smallListRoomIds = mockRoomIds.slice(0, 5);
export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);