mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-06 04:36:21 +02:00
Add RoomList component
Add RoomList component that renders a virtualized list of room items. Includes story mocks for testing.
This commit is contained in:
parent
78fa40d7e6
commit
f70180eb91
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@ -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%;
|
||||
}
|
||||
@ -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 = {};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
197
packages/shared-components/src/room-list/RoomList/RoomList.tsx
Normal file
197
packages/shared-components/src/room-list/RoomList/RoomList.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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";
|
||||
136
packages/shared-components/src/room-list/story-mocks.tsx
Normal file
136
packages/shared-components/src/room-list/story-mocks.tsx
Normal 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`);
|
||||
Loading…
x
Reference in New Issue
Block a user