Add RoomListView component

Add RoomListView component that composes RoomList with filters,
empty states, and loading skeleton.
This commit is contained in:
David Langley 2026-01-30 09:44:08 +00:00
parent f70180eb91
commit fc6318a613
25 changed files with 12168 additions and 0 deletions

View File

@ -0,0 +1,33 @@
/*
* 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.
*/
.genericPlaceholder {
align-self: center;
/** It should take 2/3 of the width **/
width: 66%;
/** It should be positioned at 1/3 of the height **/
padding-top: 33%;
}
.title {
font: var(--cpd-font-body-lg-semibold);
text-align: center;
}
.description {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
text-align: center;
}
.defaultPlaceholder {
margin-top: var(--cpd-space-4x);
}
.genericPlaceholder button {
width: 100%;
}

View File

@ -0,0 +1,182 @@
/*
* 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, type PropsWithChildren, type ReactNode } from "react";
import { Button } from "@vector-im/compound-web";
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 { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useViewModel } from "../../viewmodel";
import type { RoomListViewModel } from "./RoomListView";
import styles from "./RoomListEmptyState.module.css";
/**
* Props for RoomListEmptyState component
*/
export interface RoomListEmptyStateProps {
/** The view model containing all data and callbacks */
vm: RoomListViewModel;
}
/**
* Empty state component for the room list.
* Displays appropriate message and actions based on the active filter.
*/
export const RoomListEmptyState: React.FC<RoomListEmptyStateProps> = ({ vm }): JSX.Element => {
const snapshot = useViewModel(vm);
// If there is no active filter, show the default empty state
if (!snapshot.activeFilterId) {
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={
snapshot.canCreateRoom
? _t("room_list|empty|no_chats_description")
: _t("room_list|empty|no_chats_description_no_room_rights")
}
>
<Flex
className={styles.defaultPlaceholder}
align="center"
justify="center"
direction="column"
gap="var(--cpd-space-4x)"
>
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
{_t("action|start_chat")}
</Button>
{snapshot.canCreateRoom && (
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
{_t("action|new_room")}
</Button>
)}
</Flex>
</GenericPlaceholder>
);
}
// Handle different filter cases based on filter ID
switch (snapshot.activeFilterId) {
case "favourite":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_favourites")}
description={_t("room_list|empty|no_favourites_description")}
/>
);
case "people":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_people")}
description={_t("room_list|empty|no_people_description")}
/>
);
case "rooms":
return (
<GenericPlaceholder
title={_t("room_list|empty|no_rooms")}
description={_t("room_list|empty|no_rooms_description")}
/>
);
case "unread":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_unread")}
action={_t("room_list|empty|show_chats")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "invites":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_invites")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "mentions":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_mentions")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
case "low_priority":
return (
<ActionPlaceholder
title={_t("room_list|empty|no_lowpriority")}
action={_t("room_list|empty|show_activity")}
onAction={() => vm.onToggleFilter(snapshot.activeFilterId!)}
/>
);
default:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={_t("room_list|empty|no_chats_description")}
/>
);
}
};
interface GenericPlaceholderProps {
/** The title of the placeholder */
title: string;
/** The description of the placeholder */
description?: string;
/** Optional children (e.g., action buttons) */
children?: ReactNode;
}
/**
* A generic placeholder for the room list
*/
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
return (
<Flex
data-testid="empty-room-list"
className={styles.genericPlaceholder}
direction="column"
align="stretch"
justify="center"
gap="var(--cpd-space-2x)"
>
<span className={styles.title}>{title}</span>
{description && <span className={styles.description}>{description}</span>}
{children}
</Flex>
);
}
interface ActionPlaceholderProps {
/** The title to display */
title: string;
/** The action button text */
action: string;
/** Callback when the action button is clicked */
onAction?: () => void;
}
/**
* A placeholder for the room list when a filter is active
* The user can take action to toggle the filter
*/
function ActionPlaceholder({ title, action, onAction }: ActionPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder title={title}>
{onAction && (
<Button kind="tertiary" onClick={onAction}>
{action}
</Button>
)}
</GenericPlaceholder>
);
}

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
.skeleton {
position: relative;
margin-left: 4px;
height: 100%;
flex: 1;
}
.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;
mask-image: url("./assets/skeleton.svg");
}

View File

@ -0,0 +1,18 @@
/*
* 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 styles from "./RoomListLoadingSkeleton.module.css";
/**
* Loading skeleton component for the room list.
* Displays a repeating skeleton pattern while rooms are being fetched.
*/
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
return <div className={styles.skeleton} />;
};

View File

@ -0,0 +1,220 @@
/*
* 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 type { FilterId } from "../RoomListPrimaryFilters";
import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView";
import { useMockedViewModel } from "../../viewmodel";
import {
renderAvatar,
createGetRoomItemViewModel,
mockRoomIds,
smallListRoomIds,
largeListRoomIds,
} from "../story-mocks";
type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: any) => React.ReactElement };
const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"];
// Wrapper component that creates a mocked ViewModel
const RoomListViewWrapper = ({
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
}: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
updateVisibleRooms,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
};
const meta = {
title: "Room List/RoomListView",
component: RoomListViewWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div
style={{
width: "320px",
height: "600px",
border: "1px solid var(--cpd-color-border-interactive-primary)",
display: "flex",
flexDirection: "column",
resize: "horizontal",
overflow: "auto",
minWidth: "250px",
maxWidth: "800px",
}}
>
<Story />
</div>
),
],
args: {
// Snapshot properties (state)
isLoadingRooms: false,
isRoomListEmpty: false,
filterIds: mockFilterIds,
activeFilterId: undefined,
roomListState: {
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: undefined,
},
roomIds: mockRoomIds,
canCreateRoom: true,
// Action properties (callbacks)
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds),
updateVisibleRooms: fn(),
renderAvatar,
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=2925-19126",
},
},
} satisfies Meta<typeof RoomListViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Loading: Story = {
args: {
isLoadingRooms: true,
},
};
export const Empty: Story = {
args: {
isRoomListEmpty: true,
},
};
export const EmptyWithoutCreatePermission: Story = {
args: {
isRoomListEmpty: true,
canCreateRoom: false,
},
};
export const WithActiveFilter: Story = {
args: {
filterIds: ["unread", "people", "rooms", "favourite"],
activeFilterId: "favourite",
roomListState: {
activeRoomIndex: undefined,
spaceId: "!space:server",
filterKeys: ["favourites"],
},
},
};
export const WithSelection: Story = {
args: {
roomListState: {
activeRoomIndex: 0,
spaceId: "!space:server",
filterKeys: undefined,
},
},
};
export const EmptyFavouriteFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["favourite", "people"],
activeFilterId: "favourite",
},
};
export const EmptyPeopleFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["people", "rooms"],
activeFilterId: "people",
},
};
export const EmptyRoomsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["rooms", "people"],
activeFilterId: "rooms",
},
};
export const EmptyUnreadFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["unread", "people"],
activeFilterId: "unread",
},
};
export const EmptyInvitesFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["invites", "people"],
activeFilterId: "invites",
},
};
export const EmptyMentionsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["mentions", "people"],
activeFilterId: "mentions",
},
};
export const EmptyLowPriorityFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["low_priority", "people"],
activeFilterId: "low_priority",
},
};
export const SmallList: Story = {
args: {
roomIds: smallListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(smallListRoomIds),
},
};
export const LargeList: Story = {
args: {
roomIds: largeListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(largeListRoomIds),
},
};

View File

@ -0,0 +1,177 @@
/*
* 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 } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListView.stories";
const {
Default,
Loading,
Empty,
EmptyWithoutCreatePermission,
WithActiveFilter,
SmallList,
LargeList,
EmptyFavouriteFilter,
EmptyPeopleFilter,
EmptyRoomsFilter,
EmptyUnreadFilter,
EmptyInvitesFilter,
EmptyMentionsFilter,
EmptyLowPriorityFilter,
} = 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("<RoomListView />", () => {
it("renders Default story", () => {
const { container } = renderWithMockContext(<Default />);
expect(container).toMatchSnapshot();
});
it("renders Loading story", () => {
const { container } = renderWithMockContext(<Loading />);
expect(container).toMatchSnapshot();
});
it("renders Empty story", () => {
const { container } = renderWithMockContext(<Empty />);
expect(container).toMatchSnapshot();
});
it("renders EmptyWithoutCreatePermission story", () => {
const { container } = renderWithMockContext(<EmptyWithoutCreatePermission />);
expect(container).toMatchSnapshot();
});
it("renders WithActiveFilter story", () => {
const { container } = renderWithMockContext(<WithActiveFilter />);
expect(container).toMatchSnapshot();
});
it("renders SmallList story", () => {
const { container } = renderWithMockContext(<SmallList />);
expect(container).toMatchSnapshot();
});
it("renders LargeList story", () => {
const { container } = renderWithMockContext(<LargeList />);
expect(container).toMatchSnapshot();
});
it("renders EmptyFavouriteFilter story", () => {
const { container } = renderWithMockContext(<EmptyFavouriteFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyPeopleFilter story", () => {
const { container } = renderWithMockContext(<EmptyPeopleFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyRoomsFilter story", () => {
const { container } = renderWithMockContext(<EmptyRoomsFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyUnreadFilter story", () => {
const { container } = renderWithMockContext(<EmptyUnreadFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyInvitesFilter story", () => {
const { container } = renderWithMockContext(<EmptyInvitesFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyMentionsFilter story", () => {
const { container } = renderWithMockContext(<EmptyMentionsFilter />);
expect(container).toMatchSnapshot();
});
it("renders EmptyLowPriorityFilter story", () => {
const { container } = renderWithMockContext(<EmptyLowPriorityFilter />);
expect(container).toMatchSnapshot();
});
it("should call onToggleFilter when filter is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Default />);
await user.click(screen.getByRole("option", { name: "People" }));
expect(Default.args.onToggleFilter).toHaveBeenCalled();
});
it("should call createRoom when New room button is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Empty />);
await user.click(screen.getByRole("button", { name: "New room" }));
expect(Empty.args.createRoom).toHaveBeenCalled();
});
it("should call createChatRoom when Start chat button is clicked", async () => {
const user = userEvent.setup();
renderWithMockContext(<Empty />);
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(Empty.args.createChatRoom).toHaveBeenCalled();
});
it("should call onToggleFilter when Show all chats is clicked in unread empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyUnreadFilter />);
await user.click(screen.getByRole("button", { name: "Show all chats" }));
expect(EmptyUnreadFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in invites empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyInvitesFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyInvitesFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in mentions empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyMentionsFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyMentionsFilter.args.onToggleFilter).toHaveBeenCalled();
});
it("should call onToggleFilter when See all activity is clicked in low priority empty state", async () => {
const user = userEvent.setup();
renderWithMockContext(<EmptyLowPriorityFilter />);
await user.click(screen.getByRole("button", { name: "See all activity" }));
expect(EmptyLowPriorityFilter.args.onToggleFilter).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,101 @@
/*
* 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, type ReactNode } from "react";
import { useViewModel, type ViewModel } from "../../viewmodel";
import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters";
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyState } from "./RoomListEmptyState";
import { RoomList, type RoomListViewState } from "../RoomList";
import { type RoomListItemSnapshot } from "../RoomListItem";
/**
* Snapshot for the room list view
*/
export type RoomListSnapshot = {
/** Whether the rooms are currently loading */
isLoadingRooms: boolean;
/** Whether the room list is empty */
isRoomListEmpty: boolean;
/** Array of filter IDs */
filterIds: FilterId[];
/** Currently active filter ID (if any) */
activeFilterId?: FilterId;
/** Room list state */
roomListState: RoomListViewState;
/** Array of room IDs for virtualization */
roomIds: string[];
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
/** Whether the user can create rooms */
canCreateRoom?: boolean;
};
/**
* Actions interface for room list operations
*/
export interface RoomListViewActions {
/** Called when a filter is toggled */
onToggleFilter: (filterId: FilterId) => void;
/** Called to create a new chat room */
createChatRoom: () => void;
/** Called to create a new room */
createRoom: () => void;
/** Get view model for a specific room (virtualization API) */
getRoomItemViewModel: (roomId: string) => any;
/** Called when the visible range changes (virtualization API) */
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
}
/**
* The view model type for the room list view
*/
export type RoomListViewModel = ViewModel<RoomListSnapshot> & RoomListViewActions;
/**
* Props for RoomListView component
*/
export interface RoomListViewProps {
/** The view model containing all data and callbacks */
vm: RoomListViewModel;
/** Render function for room avatar */
renderAvatar: (roomItem: RoomListItemSnapshot) => ReactNode;
/** Optional callback for keyboard events on the room list */
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
/**
* Room list view component that manages filters, loading states, empty states, and the room list.
*/
export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, onKeyDown }): JSX.Element => {
const snapshot = useViewModel(vm);
let listBody: ReactNode;
if (snapshot.isLoadingRooms) {
listBody = <RoomListLoadingSkeleton />;
} else if (snapshot.isRoomListEmpty) {
listBody = <RoomListEmptyState vm={vm} />;
} else {
listBody = <RoomList vm={vm} renderAvatar={renderAvatar} onKeyDown={onKeyDown} />;
}
return (
<>
<div>
<RoomListPrimaryFilters
filterIds={snapshot.filterIds}
activeFilterId={snapshot.activeFilterId}
onToggleFilter={vm.onToggleFilter}
/>
</div>
{listBody}
</>
);
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,12 @@
/*
* 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 { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewModel, RoomListSnapshot, RoomListViewActions } from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyState } from "./RoomListEmptyState";
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";