Migrate the room list view to shared components (#31921)
* Add NotificationDecoration component Add the NotificationDecoration component to shared-components. This is a leaf component that renders notification badges and indicators for rooms/items including mentions, unread counts, call indicators, etc. * Add RoomListItem component Add the RoomListItem component to shared-components. Includes context menu, hover menu, notification menu, and more options menu. * Add RoomListPrimaryFilters component Add filter chips component for filtering the room list by unread, people, rooms, favourites, mentions, invites, and low priority. * Update VirtualizedList component Update VirtualizedList to support the room list virtualization requirements. * Add RoomList component Add RoomList component that renders a virtualized list of room items. Includes story mocks for testing. * Add RoomListView component Add RoomListView component that composes RoomList with filters, empty states, and loading skeleton. * Export room-list components from shared-components Add exports for RoomListView, RoomListItem, RoomListPrimaryFilters, and RoomList. Include i18n strings for room list components. * Add RoomListItemViewModel Add view model for individual room list items. Manages per-room subscriptions and updates only when specific room data changes. * Add RoomListViewViewModel Add view model for the room list view. Manages room list state, filtering, keyboard navigation, and child view models. * Integrate shared components into RoomListView Update RoomListView to use the new ViewModels and shared components. Includes i18n string updates for element-web. * Remove old room list implementation Remove old ViewModels, hooks, and view components that are now replaced by the shared-components implementation. * Update sliding-sync playwright test Update test expectations for new room list implementation. * Add figma links * Move viewModels to the right folder * Rename to RoomListEmptyStateView * Update VirtualizedRoomListView naming * Update screenshots and snapshots * Move viewmodel tests to the right location and fix some imports * lint * Use unknown as an Opaque type rather than any. It discourages property access within shared components and can still be cast back in EW. * Update screenshots for new shared component rendering params * Make room order tests deterministic
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -25,6 +25,12 @@
|
||||
"left_panel": {
|
||||
"open_dial_pad": "Open dial pad"
|
||||
},
|
||||
"notifications": {
|
||||
"all_messages": "All messages",
|
||||
"default_settings": "Match default settings",
|
||||
"mentions_keywords": "Mentions and keywords",
|
||||
"mute_room": "Mute room"
|
||||
},
|
||||
"room": {
|
||||
"context_menu": {
|
||||
"title": "Room options"
|
||||
@@ -50,8 +56,63 @@
|
||||
}
|
||||
},
|
||||
"room_list": {
|
||||
"a11y": {
|
||||
"default": "Open room %(roomName)s",
|
||||
"invitation": "Open room %(roomName)s invitation.",
|
||||
"mention": {
|
||||
"one": "Open room %(roomName)s with 1 unread mention.",
|
||||
"other": "Open room %(roomName)s with %(count)s unread mentions."
|
||||
},
|
||||
"unread": {
|
||||
"one": "Open room %(roomName)s with 1 unread message.",
|
||||
"other": "Open room %(roomName)s with %(count)s unread messages."
|
||||
},
|
||||
"unsent_message": "Open room %(roomName)s with an unsent message."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"collapse_filters": "Collapse filter list",
|
||||
"empty": {
|
||||
"no_chats": "No chats yet",
|
||||
"no_chats_description": "Get started by messaging someone or by creating a room",
|
||||
"no_chats_description_no_room_rights": "Get started by messaging someone",
|
||||
"no_favourites": "You don't have favourite chats yet",
|
||||
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
|
||||
"no_invites": "You don't have any unread invites",
|
||||
"no_lowpriority": "You don't have any low priority rooms",
|
||||
"no_mentions": "You don't have any unread mentions",
|
||||
"no_people": "You don’t have direct chats with anyone yet",
|
||||
"no_people_description": "You can deselect filters in order to see your other chats",
|
||||
"no_rooms": "You’re not in any room yet",
|
||||
"no_rooms_description": "You can deselect filters in order to see your other chats",
|
||||
"no_unread": "Congrats! You don’t have any unread messages",
|
||||
"show_activity": "See all activity",
|
||||
"show_chats": "Show all chats"
|
||||
},
|
||||
"expand_filters": "Expand filter list",
|
||||
"filters": {
|
||||
"favourite": "Favourites",
|
||||
"invites": "Invites",
|
||||
"low_priority": "Low priority",
|
||||
"mentions": "Mentions",
|
||||
"people": "People",
|
||||
"rooms": "Rooms",
|
||||
"unread": "Unreads"
|
||||
},
|
||||
"list_title": "Room list",
|
||||
"more_options": {
|
||||
"copy_link": "Copy room link",
|
||||
"favourited": "Favourited",
|
||||
"leave_room": "Leave room",
|
||||
"low_priority": "Low priority",
|
||||
"mark_read": "Mark as read",
|
||||
"mark_unread": "Mark as unread"
|
||||
},
|
||||
"notification_options": "Notification options",
|
||||
"open_space_menu": "Open space menu",
|
||||
"primary_filters": "Room list filters",
|
||||
"room": {
|
||||
"more_options": "More Options"
|
||||
},
|
||||
"room_options": "Room Options",
|
||||
"show_message_previews": "Show message previews",
|
||||
"sort": "Sort",
|
||||
|
||||
@@ -27,6 +27,10 @@ export * from "./rich-list/RichItem";
|
||||
export * from "./rich-list/RichList";
|
||||
export * from "./room-list/RoomListHeaderView";
|
||||
export * from "./room-list/RoomListSearchView";
|
||||
export * from "./room-list/RoomListView";
|
||||
export * from "./room-list/RoomListItem";
|
||||
export * from "./room-list/RoomListPrimaryFilters";
|
||||
export * from "./room-list/VirtualizedRoomListView";
|
||||
export * from "./utils/Box";
|
||||
export * from "./utils/Flex";
|
||||
export * from "./right-panel/WidgetContextMenu";
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { NotificationDecoration, type NotificationDecorationProps } from "./NotificationDecoration";
|
||||
|
||||
const defaultProps: NotificationDecorationProps = {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
hasUnreadCount: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/NotificationDecoration",
|
||||
component: NotificationDecoration,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ padding: "16px", backgroundColor: "var(--cpd-color-bg-canvas-default)" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: defaultProps,
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof NotificationDecoration>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const NoNotification: Story = {};
|
||||
|
||||
export const UnsentMessage: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoCall: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
callType: "video",
|
||||
},
|
||||
};
|
||||
|
||||
export const VoiceCall: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
callType: "voice",
|
||||
},
|
||||
};
|
||||
|
||||
export const Invited: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
invited: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Mention: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isMention: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MentionWithCount: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isMention: true,
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotificationWithCount: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isNotification: true,
|
||||
count: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const ActivityIndicator: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isActivityNotification: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {
|
||||
muted: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MutedWithoutActivity: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
muted: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoCallWithoutActivity: Story = {
|
||||
args: {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
callType: "video",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./NotificationDecoration.stories";
|
||||
|
||||
const {
|
||||
NoNotification,
|
||||
UnsentMessage,
|
||||
VideoCall,
|
||||
VoiceCall,
|
||||
Invited,
|
||||
Mention,
|
||||
MentionWithCount,
|
||||
NotificationWithCount,
|
||||
ActivityIndicator,
|
||||
Muted,
|
||||
} = composeStories(stories);
|
||||
|
||||
describe("<NotificationDecoration />", () => {
|
||||
describe("snapshots", () => {
|
||||
it("renders NoNotification story", () => {
|
||||
const { container } = render(<NoNotification />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders UnsentMessage story", () => {
|
||||
const { container } = render(<UnsentMessage />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders VideoCall story", () => {
|
||||
const { container } = render(<VideoCall />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders VoiceCall story", () => {
|
||||
const { container } = render(<VoiceCall />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Invited story", () => {
|
||||
const { container } = render(<Invited />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Mention story", () => {
|
||||
const { container } = render(<Mention />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders MentionWithCount story", () => {
|
||||
const { container } = render(<MentionWithCount />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders NotificationWithCount story", () => {
|
||||
const { container } = render(<NotificationWithCount />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders ActivityIndicator story", () => {
|
||||
const { container } = render(<ActivityIndicator />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Muted story", () => {
|
||||
const { container } = render(<Muted />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {
|
||||
MentionIcon,
|
||||
ErrorSolidIcon,
|
||||
NotificationsOffSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
EmailSolidIcon,
|
||||
VoiceCallSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
|
||||
/**
|
||||
* Data representing the notification state for a room or item.
|
||||
* Used in snapshots and passed to the NotificationDecoration component.
|
||||
*/
|
||||
export interface NotificationDecorationData {
|
||||
/** 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;
|
||||
/** Whether there are unread messages with a count */
|
||||
hasUnreadCount: boolean;
|
||||
/** Notification count */
|
||||
count: number;
|
||||
/** Whether notifications are muted */
|
||||
muted: boolean;
|
||||
/** Optional call type indicator */
|
||||
callType?: "video" | "voice";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the NotificationDecoration component.
|
||||
*/
|
||||
export interface NotificationDecorationProps extends NotificationDecorationData {}
|
||||
|
||||
/**
|
||||
* Renders notification badges and indicators for rooms/items
|
||||
*/
|
||||
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({
|
||||
hasAnyNotificationOrActivity,
|
||||
muted,
|
||||
callType,
|
||||
isUnsentMessage,
|
||||
invited,
|
||||
isMention,
|
||||
isNotification,
|
||||
isActivityNotification,
|
||||
count,
|
||||
}) => {
|
||||
// Don't render anything if there's nothing to show
|
||||
if (!hasAnyNotificationOrActivity && !muted && !callType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
|
||||
{isUnsentMessage && (
|
||||
<ErrorSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
|
||||
)}
|
||||
{callType === "video" && (
|
||||
<VideoCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{callType === "voice" && (
|
||||
<VoiceCallSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{invited && <EmailSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
|
||||
{isActivityNotification && <Unread />}
|
||||
{muted && <NotificationsOffSolidIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders ActivityIndicator story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_unread_cti0f_8"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders Invited story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 5.111a1 1 0 0 0 .514.874l7 3.89a1 1 0 0 0 .972 0l7-3.89a1 1 0 1 0-.972-1.748L12 11.856 5.486 8.237A1 1 0 0 0 4 9.111"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders Mention story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="_unread-counter_1147r_8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders MentionWithCount story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_unread-counter_1147r_8"
|
||||
>
|
||||
5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders Muted story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-tertiary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
|
||||
/>
|
||||
<path
|
||||
d="M10 20h4a2 2 0 0 1-4 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders NoNotification story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders NotificationWithCount story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="_unread-counter_1147r_8"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders UnsentMessage story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-critical-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders VideoCall story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> > snapshots > renders VoiceCall story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="padding: 16px; background-color: var(--cpd-color-bg-canvas-default);"
|
||||
>
|
||||
<div
|
||||
class="flex"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 { NotificationDecoration } from "./NotificationDecoration";
|
||||
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/* Hide the menu by default */
|
||||
.hoverMenu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show hover menu and background on hover/focus/menu-open states */
|
||||
.roomListItem:hover,
|
||||
.roomListItem:focus-visible,
|
||||
/* When the context menu is opened */
|
||||
.roomListItem[data-state="open"],
|
||||
/* When the options and notifications menu are opened */
|
||||
.roomListItem:has(.hoverMenu > button[data-state="open"]) {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
|
||||
.hoverMenu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* When the menu is visible, hide the notification decoration to avoid clutter */
|
||||
.notificationDecoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
.content {
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--cpd-color-bg-action-secondary-pressed);
|
||||
}
|
||||
|
||||
.bold .roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
|
||||
/* Set icon color for hover menu buttons */
|
||||
.hoverMenu svg {
|
||||
fill: var(--cpd-color-icon-primary);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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 { Room } from "./RoomListItem";
|
||||
import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItem";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
import { renderAvatar } from "../story-mocks";
|
||||
|
||||
type RoomListItemProps = RoomListItemSnapshot &
|
||||
RoomListItemActions & {
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
onFocus: (room: Room, e: React.FocusEvent) => void;
|
||||
roomIndex: number;
|
||||
roomCount: number;
|
||||
renderAvatar: (room: Room) => React.ReactElement;
|
||||
};
|
||||
|
||||
// Wrapper component that creates a mocked ViewModel
|
||||
const RoomListItemWrapper = ({
|
||||
onOpenRoom,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onToggleFavorite,
|
||||
onToggleLowPriority,
|
||||
onInvite,
|
||||
onCopyRoomLink,
|
||||
onLeaveRoom,
|
||||
onSetRoomNotifState,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onFocus,
|
||||
roomIndex,
|
||||
roomCount,
|
||||
renderAvatar: renderAvatarProp,
|
||||
...rest
|
||||
}: RoomListItemProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onOpenRoom,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onToggleFavorite,
|
||||
onToggleLowPriority,
|
||||
onInvite,
|
||||
onCopyRoomLink,
|
||||
onLeaveRoom,
|
||||
onSetRoomNotifState,
|
||||
});
|
||||
return (
|
||||
<RoomListItemView
|
||||
vm={vm}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
onFocus={onFocus}
|
||||
roomIndex={roomIndex}
|
||||
roomCount={roomCount}
|
||||
renderAvatar={renderAvatarProp}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListItem",
|
||||
component: RoomListItemWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px", padding: "8px" }}>
|
||||
<div role="listbox" aria-label="Room list">
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
...defaultSnapshot,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
roomIndex: 0,
|
||||
roomCount: 10,
|
||||
onOpenRoom: fn(),
|
||||
onMarkAsRead: fn(),
|
||||
onMarkAsUnread: fn(),
|
||||
onToggleFavorite: fn(),
|
||||
onToggleLowPriority: fn(),
|
||||
onInvite: fn(),
|
||||
onCopyRoomLink: fn(),
|
||||
onLeaveRoom: fn(),
|
||||
onSetRoomNotifState: fn(),
|
||||
onFocus: fn(),
|
||||
renderAvatar,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel?node-id=101-13062",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RoomListItemWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
isSelected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Bold: Story = {
|
||||
args: {
|
||||
isBold: true,
|
||||
name: "Team Updates",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNotification: Story = {
|
||||
args: {
|
||||
isBold: true,
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: true,
|
||||
hasUnreadCount: true,
|
||||
count: 3,
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMention: Story = {
|
||||
args: {
|
||||
isBold: true,
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: true,
|
||||
isActivityNotification: false,
|
||||
isNotification: true,
|
||||
hasUnreadCount: true,
|
||||
count: 1,
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Invitation: Story = {
|
||||
args: {
|
||||
name: "Secret Project",
|
||||
messagePreview: "Bob invited you",
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: true,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
hasUnreadCount: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UnsentMessage: Story = {
|
||||
args: {
|
||||
messagePreview: "Failed to send message",
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: true,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
hasUnreadCount: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMessagePreview: Story = {
|
||||
args: {
|
||||
messagePreview: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHoverMenu: Story = {
|
||||
args: {
|
||||
showMoreOptionsMenu: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutHoverMenu: Story = {
|
||||
args: {
|
||||
showMoreOptionsMenu: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListItem.stories";
|
||||
|
||||
const {
|
||||
Default,
|
||||
Selected,
|
||||
Bold,
|
||||
WithNotification,
|
||||
WithMention,
|
||||
Invitation,
|
||||
UnsentMessage,
|
||||
NoMessagePreview,
|
||||
WithHoverMenu,
|
||||
WithoutHoverMenu,
|
||||
} = composeStories(stories);
|
||||
|
||||
describe("<RoomListItemView />", () => {
|
||||
it("renders Default story", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Selected story", () => {
|
||||
const { container } = render(<Selected />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Bold story", () => {
|
||||
const { container } = render(<Bold />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders WithNotification story", () => {
|
||||
const { container } = render(<WithNotification />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders WithMention story", () => {
|
||||
const { container } = render(<WithMention />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders Invitation story", () => {
|
||||
const { container } = render(<Invitation />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders UnsentMessage story", () => {
|
||||
const { container } = render(<UnsentMessage />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders NoMessagePreview story", () => {
|
||||
const { container } = render(<NoMessagePreview />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders WithHoverMenu story", () => {
|
||||
const { container } = render(<WithHoverMenu />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should call onOpenRoom when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Default />);
|
||||
|
||||
await user.click(screen.getByRole("option"));
|
||||
expect(Default.args.onOpenRoom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should have aria-selected true when selected", () => {
|
||||
render(<Selected />);
|
||||
expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should have aria-selected false when not selected", () => {
|
||||
render(<Default />);
|
||||
expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("should have tabIndex -1 when not focused", () => {
|
||||
render(<Default />);
|
||||
expect(screen.getByRole("option")).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
it("should call onFocus when focused", () => {
|
||||
render(<Default />);
|
||||
screen.getByRole("option").focus();
|
||||
expect(Default.args.onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display notification decoration when present", () => {
|
||||
render(<WithNotification />);
|
||||
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide notification decoration when not present", () => {
|
||||
render(<Default />);
|
||||
expect(screen.queryByTestId("notification-decoration")).toBeNull();
|
||||
});
|
||||
|
||||
it("should show hover menu when showMoreOptionsMenu is true", () => {
|
||||
const { container } = render(<WithHoverMenu />);
|
||||
expect(container.querySelector('[aria-label="More Options"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should hide hover menu when showMoreOptionsMenu is false", () => {
|
||||
const { container } = render(<WithoutHoverMenu />);
|
||||
expect(container.querySelector('[aria-label="More Options"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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, memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
|
||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||
import { type RoomNotifState } from "./RoomNotifs";
|
||||
import styles from "./RoomListItem.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
/**
|
||||
* Opaque type representing a Room object from the parent application
|
||||
*/
|
||||
export type Room = unknown;
|
||||
|
||||
/**
|
||||
* Generate an accessible label for a room based on its notification state.
|
||||
*/
|
||||
function getA11yLabel(roomName: string, notification: NotificationDecorationData): string {
|
||||
if (notification.isUnsentMessage) {
|
||||
return _t("room_list|a11y|unsent_message", { roomName });
|
||||
} else if (notification.invited) {
|
||||
return _t("room_list|a11y|invitation", { roomName });
|
||||
} else if (notification.isMention && notification.count) {
|
||||
return _t("room_list|a11y|mention", { roomName, count: notification.count });
|
||||
} else if (notification.hasUnreadCount && notification.count) {
|
||||
return _t("room_list|a11y|unread", { roomName, count: notification.count });
|
||||
} else {
|
||||
return _t("room_list|a11y|default", { roomName });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot for a room list item.
|
||||
* Contains all the data needed to render a room in the list.
|
||||
*/
|
||||
export interface RoomListItemSnapshot {
|
||||
/** Unique identifier for the room (used for list keying) */
|
||||
id: string;
|
||||
/** The opaque Room object from the client (e.g., matrix-js-sdk Room) */
|
||||
room: Room;
|
||||
/** The name of the room */
|
||||
name: string;
|
||||
/** Whether the room name should be bolded (has unread/activity) */
|
||||
isBold: boolean;
|
||||
/** Optional message preview text */
|
||||
messagePreview?: string;
|
||||
/** Notification decoration data */
|
||||
notification: NotificationDecorationData;
|
||||
/** 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;
|
||||
/** The room's notification state */
|
||||
roomNotifState: RoomNotifState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions interface for room list item operations.
|
||||
* Implemented by the room item view model.
|
||||
*/
|
||||
export interface RoomListItemActions {
|
||||
/** Called when the room should be opened */
|
||||
onOpenRoom: () => void;
|
||||
/** Called when the room should be marked as read */
|
||||
onMarkAsRead: () => void;
|
||||
/** Called when the room should be marked as unread */
|
||||
onMarkAsUnread: () => void;
|
||||
/** Called when the room's favorite status should be toggled */
|
||||
onToggleFavorite: () => void;
|
||||
/** Called when the room's low priority status should be toggled */
|
||||
onToggleLowPriority: () => void;
|
||||
/** Called when inviting users to the room */
|
||||
onInvite: () => void;
|
||||
/** Called when copying the room link */
|
||||
onCopyRoomLink: () => void;
|
||||
/** Called when leaving the room */
|
||||
onLeaveRoom: () => void;
|
||||
/** Called when setting the room notification state */
|
||||
onSetRoomNotifState: (state: RoomNotifState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model type for a room list item
|
||||
*/
|
||||
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
|
||||
|
||||
/**
|
||||
* Props for RoomListItemView component
|
||||
*/
|
||||
export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
|
||||
/** The room item view model */
|
||||
vm: RoomItemViewModel;
|
||||
/** Whether the room is selected */
|
||||
isSelected: boolean;
|
||||
/** Whether the room should be focused */
|
||||
isFocused: boolean;
|
||||
/** Callback when item receives focus */
|
||||
onFocus: (roomId: string, e: React.FocusEvent) => void;
|
||||
/** Index of this room in the list (for accessibility) */
|
||||
roomIndex: number;
|
||||
/** Total number of rooms in the list (for accessibility) */
|
||||
roomCount: number;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A presentational room list item component.
|
||||
* Displays room name, avatar, message preview, and notifications.
|
||||
*/
|
||||
export const RoomListItemView = memo(function RoomListItemView({
|
||||
vm,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onFocus,
|
||||
roomIndex,
|
||||
roomCount,
|
||||
renderAvatar,
|
||||
...props
|
||||
}: RoomListItemViewProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const item = useViewModel(vm);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions);
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
// Generate a11y label from notification state and room name
|
||||
const a11yLabel = getA11yLabel(item.name, item.notification);
|
||||
|
||||
const content = (
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.bold]: item.isBold,
|
||||
mx_RoomListItemView_selected: isSelected,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
type="button"
|
||||
role="option"
|
||||
aria-posinset={roomIndex + 1}
|
||||
aria-setsize={roomCount}
|
||||
aria-selected={isSelected}
|
||||
aria-label={a11yLabel}
|
||||
onClick={vm.onOpenRoom}
|
||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
{...props}
|
||||
>
|
||||
{renderAvatar(item.room)}
|
||||
<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={item.name} data-testid="room-name">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.messagePreview && (
|
||||
<div className={styles.messagePreview} title={item.messagePreview}>
|
||||
{item.messagePreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
|
||||
<RoomListItemHoverMenu
|
||||
showMoreOptionsMenu={item.showMoreOptionsMenu}
|
||||
showNotificationMenu={item.showNotificationMenu}
|
||||
vm={vm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...item.notification} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return <RoomListItemContextMenu vm={vm}>{content}</RoomListItemContextMenu>;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 } from "react";
|
||||
import { ContextMenu } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { MoreOptionContent, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu";
|
||||
|
||||
/**
|
||||
* Props for RoomListItemContextMenu component
|
||||
*/
|
||||
export interface RoomListItemContextMenuProps {
|
||||
/** The room item view model */
|
||||
vm: RoomItemViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>> = ({
|
||||
vm,
|
||||
children,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<ContextMenu
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
hasAccessibleAlternative={true}
|
||||
trigger={children}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { Flex } from "../../utils/Flex";
|
||||
import { RoomListItemMoreOptionsMenu, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu";
|
||||
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
import styles from "./RoomListItem.module.css";
|
||||
|
||||
/**
|
||||
* Props for RoomListItemHoverMenu component
|
||||
*/
|
||||
export interface RoomListItemHoverMenuProps {
|
||||
/** Whether the more options menu should be shown */
|
||||
showMoreOptionsMenu: boolean;
|
||||
/** Whether the notification menu should be shown */
|
||||
showNotificationMenu: boolean;
|
||||
/** The room item view model */
|
||||
vm: RoomItemViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hover menu for room list items.
|
||||
* Displays more options and notification settings menus.
|
||||
*/
|
||||
export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
|
||||
showMoreOptionsMenu,
|
||||
showNotificationMenu,
|
||||
vm,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Flex className={styles.hoverMenu} align="center" gap="var(--cpd-space-1x)">
|
||||
{showMoreOptionsMenu && <RoomListItemMoreOptionsMenu vm={vm} />}
|
||||
{showNotificationMenu && <RoomListItemNotificationMenu vm={vm} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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 { render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItem";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
|
||||
describe("<RoomListItemMoreOptionsMenu />", () => {
|
||||
const mockCallbacks = {
|
||||
onOpenRoom: vi.fn(),
|
||||
onMarkAsRead: vi.fn(),
|
||||
onMarkAsUnread: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onToggleLowPriority: vi.fn(),
|
||||
onInvite: vi.fn(),
|
||||
onCopyRoomLink: vi.fn(),
|
||||
onLeaveRoom: vi.fn(),
|
||||
onSetRoomNotifState: vi.fn(),
|
||||
};
|
||||
|
||||
const renderMenu = (overrides: Partial<RoomListItemSnapshot> = {}): ReturnType<typeof render> => {
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const vm = useMockedViewModel(
|
||||
{
|
||||
...defaultSnapshot,
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: false,
|
||||
...overrides,
|
||||
} as RoomListItemSnapshot,
|
||||
mockCallbacks,
|
||||
);
|
||||
return <RoomListItemMoreOptionsMenu vm={vm} />;
|
||||
};
|
||||
return render(<TestComponent />);
|
||||
};
|
||||
|
||||
it("should render the more options button", () => {
|
||||
renderMenu();
|
||||
expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open menu when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show mark as read option when canMarkAsRead is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canMarkAsRead: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Mark as read" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show mark as read option when canMarkAsRead is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canMarkAsRead: false });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.queryByRole("menuitem", { name: "Mark as read" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onMarkAsRead when mark as read clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canMarkAsRead: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const markAsReadOption = screen.getByRole("menuitem", { name: "Mark as read" });
|
||||
await user.click(markAsReadOption);
|
||||
|
||||
expect(mockCallbacks.onMarkAsRead).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show mark as unread option when canMarkAsUnread is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canMarkAsUnread: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Mark as unread" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onMarkAsUnread when mark as unread clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canMarkAsUnread: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const markAsUnreadOption = screen.getByRole("menuitem", { name: "Mark as unread" });
|
||||
await user.click(markAsUnreadOption);
|
||||
|
||||
expect(mockCallbacks.onMarkAsUnread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show favorite option and call onToggleFavorite", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ isFavourite: false });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" });
|
||||
expect(favoriteOption).toBeInTheDocument();
|
||||
expect(favoriteOption).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(favoriteOption);
|
||||
expect(mockCallbacks.onToggleFavorite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show favorite as checked when isFavourite is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ isFavourite: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const favoriteOption = screen.getByRole("menuitemcheckbox", { name: "Favourited" });
|
||||
expect(favoriteOption).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("should show low priority option and call onToggleLowPriority", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ isLowPriority: false });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const lowPriorityOption = screen.getByRole("menuitemcheckbox", { name: "Low priority" });
|
||||
expect(lowPriorityOption).toBeInTheDocument();
|
||||
expect(lowPriorityOption).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(lowPriorityOption);
|
||||
expect(mockCallbacks.onToggleLowPriority).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show invite option when canInvite is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canInvite: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onInvite when invite clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canInvite: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const inviteOption = screen.getByRole("menuitem", { name: "Invite" });
|
||||
await user.click(inviteOption);
|
||||
|
||||
expect(mockCallbacks.onInvite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show copy link option when canCopyRoomLink is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canCopyRoomLink: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Copy room link" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCopyRoomLink when copy link clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu({ canCopyRoomLink: true });
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const copyLinkOption = screen.getByRole("menuitem", { name: "Copy room link" });
|
||||
await user.click(copyLinkOption);
|
||||
|
||||
expect(mockCallbacks.onCopyRoomLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show leave room option", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menuitem", { name: "Leave room" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLeaveRoom when leave room clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "More Options" });
|
||||
await user.click(button);
|
||||
|
||||
const leaveRoomOption = screen.getByRole("menuitem", { name: "Leave room" });
|
||||
await user.click(leaveRoomOption);
|
||||
|
||||
expect(mockCallbacks.onLeaveRoom).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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, { useState, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
MarkAsReadIcon,
|
||||
MarkAsUnreadIcon,
|
||||
FavouriteIcon,
|
||||
ArrowDownIcon,
|
||||
UserAddIcon,
|
||||
LinkIcon,
|
||||
LeaveIcon,
|
||||
OverflowHorizontalIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
|
||||
|
||||
/**
|
||||
* View model type for room list item
|
||||
*/
|
||||
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
|
||||
|
||||
/**
|
||||
* Props for RoomListItemMoreOptionsMenu component
|
||||
*/
|
||||
export interface RoomListItemMoreOptionsMenuProps {
|
||||
/** The room item view model */
|
||||
vm: RoomItemViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The more options menu for room list items.
|
||||
* Displays additional room actions like mark as read/unread, favorite, invite, etc.
|
||||
*/
|
||||
export function RoomListItemMoreOptionsMenu({ vm }: RoomListItemMoreOptionsMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
tooltip={_t("room_list|room|more_options")}
|
||||
aria-label={_t("room_list|room|more_options")}
|
||||
size="24px"
|
||||
>
|
||||
<OverflowHorizontalIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MoreOptionContent vm={vm} />
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreOptionContentProps {
|
||||
vm: RoomItemViewModel;
|
||||
}
|
||||
|
||||
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
|
||||
const snapshot = useViewModel(vm);
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onKeyDown={(e) => e.stopPropagation()}>
|
||||
{snapshot.canMarkAsRead && (
|
||||
<MenuItem
|
||||
Icon={MarkAsReadIcon}
|
||||
label={_t("room_list|more_options|mark_read")}
|
||||
onSelect={vm.onMarkAsRead}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{snapshot.canMarkAsUnread && (
|
||||
<MenuItem
|
||||
Icon={MarkAsUnreadIcon}
|
||||
label={_t("room_list|more_options|mark_unread")}
|
||||
onSelect={vm.onMarkAsUnread}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<ToggleMenuItem
|
||||
checked={snapshot.isFavourite}
|
||||
Icon={FavouriteIcon}
|
||||
label={_t("room_list|more_options|favourited")}
|
||||
onSelect={vm.onToggleFavorite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
<ToggleMenuItem
|
||||
checked={snapshot.isLowPriority}
|
||||
Icon={ArrowDownIcon}
|
||||
label={_t("room_list|more_options|low_priority")}
|
||||
onSelect={vm.onToggleLowPriority}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
{snapshot.canInvite && (
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={vm.onInvite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{snapshot.canCopyRoomLink && (
|
||||
<MenuItem
|
||||
Icon={LinkIcon}
|
||||
label={_t("room_list|more_options|copy_link")}
|
||||
onSelect={vm.onCopyRoomLink}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<Separator />
|
||||
<MenuItem
|
||||
kind="critical"
|
||||
Icon={LeaveIcon}
|
||||
label={_t("room_list|more_options|leave_room")}
|
||||
onSelect={vm.onLeaveRoom}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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 { render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot } from "./RoomListItem";
|
||||
import { defaultSnapshot } from "./default-snapshot";
|
||||
|
||||
describe("<RoomListItemNotificationMenu />", () => {
|
||||
const mockCallbacks = {
|
||||
onOpenRoom: vi.fn(),
|
||||
onMarkAsRead: vi.fn(),
|
||||
onMarkAsUnread: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onToggleLowPriority: vi.fn(),
|
||||
onInvite: vi.fn(),
|
||||
onCopyRoomLink: vi.fn(),
|
||||
onLeaveRoom: vi.fn(),
|
||||
onSetRoomNotifState: vi.fn(),
|
||||
};
|
||||
|
||||
const renderMenu = (roomNotifState: RoomNotifState = RoomNotifState.AllMessages): ReturnType<typeof render> => {
|
||||
const TestComponent = (): JSX.Element => {
|
||||
const vm = useMockedViewModel(
|
||||
{
|
||||
...defaultSnapshot,
|
||||
showMoreOptionsMenu: false,
|
||||
showNotificationMenu: true,
|
||||
roomNotifState,
|
||||
} as RoomListItemSnapshot,
|
||||
mockCallbacks,
|
||||
);
|
||||
return <RoomListItemNotificationMenu vm={vm} />;
|
||||
};
|
||||
return render(<TestComponent />);
|
||||
};
|
||||
|
||||
it("should render the notification menu button", () => {
|
||||
renderMenu();
|
||||
expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show muted icon when notifications are muted", () => {
|
||||
renderMenu(RoomNotifState.Mute);
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
expect(button.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open menu when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSetRoomNotifState with AllMessages when default settings selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" });
|
||||
await user.click(defaultOption);
|
||||
|
||||
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages);
|
||||
});
|
||||
|
||||
it("should call onSetRoomNotifState with AllMessagesLoud when all messages selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" });
|
||||
await user.click(allMessagesOption);
|
||||
|
||||
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud);
|
||||
});
|
||||
|
||||
it("should call onSetRoomNotifState with MentionsOnly when mentions and keywords selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" });
|
||||
await user.click(mentionsOption);
|
||||
|
||||
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly);
|
||||
});
|
||||
|
||||
it("should call onSetRoomNotifState with Mute when mute selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const muteOption = screen.getByRole("menuitem", { name: "Mute room" });
|
||||
await user.click(muteOption);
|
||||
|
||||
expect(mockCallbacks.onSetRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
|
||||
});
|
||||
|
||||
it("should show check mark next to selected option - AllMessage", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu(RoomNotifState.AllMessages);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const defaultOption = screen.getByRole("menuitem", { name: "Match default settings" });
|
||||
expect(defaultOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should show check mark next to selected option - AllMessagesLoud", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu(RoomNotifState.AllMessagesLoud);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const allMessagesOption = screen.getByRole("menuitem", { name: "All messages" });
|
||||
expect(allMessagesOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should show check mark next to selected option - MentionsOnly", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu(RoomNotifState.MentionsOnly);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const mentionsOption = screen.getByRole("menuitem", { name: "Mentions and keywords" });
|
||||
expect(mentionsOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should show check mark next to selected option - Mute", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMenu(RoomNotifState.Mute);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Notification options" });
|
||||
await user.click(button);
|
||||
|
||||
const muteOption = screen.getByRole("menuitem", { name: "Mute room" });
|
||||
expect(muteOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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, { useState, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
NotificationsSolidIcon,
|
||||
NotificationsOffSolidIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItem";
|
||||
|
||||
/**
|
||||
* View model type for room list item
|
||||
*/
|
||||
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot> & RoomListItemActions;
|
||||
|
||||
/**
|
||||
* Props for RoomListItemNotificationMenu component
|
||||
*/
|
||||
export interface RoomListItemNotificationMenuProps {
|
||||
/** The room item view model */
|
||||
vm: RoomItemViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification settings menu for room list items.
|
||||
* Displays options to change notification settings.
|
||||
*/
|
||||
export function RoomListItemNotificationMenu({ vm }: RoomListItemNotificationMenuProps): JSX.Element {
|
||||
const snapshot = useViewModel(vm);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isMuted = snapshot.roomNotifState === RoomNotifState.Mute;
|
||||
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|notification_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
size="24px"
|
||||
tooltip={_t("room_list|notification_options")}
|
||||
aria-label={_t("room_list|notification_options")}
|
||||
>
|
||||
{isMuted ? <NotificationsOffSolidIcon /> : <NotificationsSolidIcon />}
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
aria-selected={snapshot.roomNotifState === RoomNotifState.AllMessages}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|default_settings")}
|
||||
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.AllMessages)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{snapshot.roomNotifState === RoomNotifState.AllMessages && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={snapshot.roomNotifState === RoomNotifState.AllMessagesLoud}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|all_messages")}
|
||||
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.AllMessagesLoud)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{snapshot.roomNotifState === RoomNotifState.AllMessagesLoud && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={snapshot.roomNotifState === RoomNotifState.MentionsOnly}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mentions_keywords")}
|
||||
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.MentionsOnly)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{snapshot.roomNotifState === RoomNotifState.MentionsOnly && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={snapshot.roomNotifState === RoomNotifState.Mute}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mute_room")}
|
||||
onSelect={() => vm.onSetRoomNotifState(RoomNotifState.Mute)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{snapshot.roomNotifState === RoomNotifState.Mute && checkComponent}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Notification state for a room.
|
||||
*/
|
||||
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",
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { type RoomListItemSnapshot } from "./RoomListItem";
|
||||
import { RoomNotifState } from "./RoomNotifs";
|
||||
|
||||
export const mockRoom = { name: "General" };
|
||||
|
||||
export const defaultSnapshot: RoomListItemSnapshot = {
|
||||
id: "!room:server",
|
||||
room: mockRoom,
|
||||
name: "General",
|
||||
isBold: false,
|
||||
messagePreview: "Alice: Hey everyone!",
|
||||
notification: {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
hasUnreadCount: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
},
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
canMarkAsRead: false,
|
||||
canMarkAsUnread: true,
|
||||
roomNotifState: RoomNotifState.AllMessages,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { RoomListItemView } from "./RoomListItem";
|
||||
export type {
|
||||
Room,
|
||||
RoomListItemSnapshot,
|
||||
RoomItemViewModel,
|
||||
RoomListItemActions,
|
||||
RoomListItemViewProps,
|
||||
} from "./RoomListItem";
|
||||
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
||||
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
||||
export type { RoomListItemMoreOptionsMenuProps } from "./RoomListItemMoreOptionsMenu";
|
||||
export { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
export type { RoomListItemHoverMenuProps } from "./RoomListItemHoverMenu";
|
||||
export { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||
export type { RoomListItemContextMenuProps } from "./RoomListItemContextMenu";
|
||||
export { NotificationDecoration } from "./NotificationDecoration";
|
||||
export type { NotificationDecorationProps, NotificationDecorationData } from "./NotificationDecoration";
|
||||
export { RoomNotifState } from "./RoomNotifs";
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.roomListPrimaryFilters {
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
/* Hide filters that are wrapping when collapsed */
|
||||
.roomListPrimaryFilters :global(.wrapping) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.list {
|
||||
/**
|
||||
* The InteractionObserver needs the height to be set to work properly.
|
||||
*/
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* IconButton styles for chevron */
|
||||
.iconButton svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.iconButton[aria-expanded="true"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
import type { FilterId } from "./useVisibleFilters";
|
||||
|
||||
const meta: Meta<typeof RoomListPrimaryFilters> = {
|
||||
title: "Room List/RoomListPrimaryFilters",
|
||||
component: RoomListPrimaryFilters,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
onToggleFilter: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoomListPrimaryFilters>;
|
||||
|
||||
// All available filter IDs
|
||||
const allFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite", "mentions", "invites", "low_priority"];
|
||||
|
||||
// Subset of filters for narrow container tests
|
||||
const fewFilterIds: FilterId[] = ["people", "rooms", "unread"];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
filterIds: allFilterIds,
|
||||
},
|
||||
};
|
||||
|
||||
export const PeopleSelected: Story = {
|
||||
args: {
|
||||
filterIds: allFilterIds,
|
||||
activeFilterId: "people",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFilters: Story = {
|
||||
args: {
|
||||
filterIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow container that causes filters to wrap.
|
||||
* The chevron button should appear to expand/collapse the filter list.
|
||||
*/
|
||||
export const NarrowContainer: Story = {
|
||||
args: {
|
||||
filterIds: fewFilterIds,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow container with active filter that would wrap.
|
||||
* When collapsed, the active filter should move to the front.
|
||||
*/
|
||||
export const NarrowWithActiveWrappingFilter: Story = {
|
||||
args: {
|
||||
filterIds: fewFilterIds,
|
||||
activeFilterId: "unread",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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, { act } from "react";
|
||||
import { render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListPrimaryFilters.stories";
|
||||
|
||||
const { Default, PeopleSelected, NoFilters, NarrowContainer, NarrowWithActiveWrappingFilter } = composeStories(stories);
|
||||
|
||||
describe("<RoomListPrimaryFilters /> stories", () => {
|
||||
describe("snapshots", () => {
|
||||
it("renders Default story", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders PeopleSelected story", () => {
|
||||
const { container } = render(<PeopleSelected />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders NoFilters story", () => {
|
||||
const { container } = render(<NoFilters />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders NarrowContainer story", () => {
|
||||
const { container } = render(<NarrowContainer />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders NarrowWithActiveWrappingFilter story", () => {
|
||||
const { container } = render(<NarrowWithActiveWrappingFilter />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("behavior", () => {
|
||||
it("should call onToggleFilter when a filter is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Default />);
|
||||
|
||||
await user.click(screen.getByRole("option", { name: "People" }));
|
||||
|
||||
expect(Default.args.onToggleFilter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize behavior", () => {
|
||||
let resizeCallback: ResizeObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = class MockResizeObserver {
|
||||
public constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
public observe = vi.fn();
|
||||
public unobserve = vi.fn();
|
||||
public disconnect = vi.fn();
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
function mockFiltersNotWrapping(): void {
|
||||
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
|
||||
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
|
||||
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60);
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
|
||||
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
|
||||
}
|
||||
|
||||
function mockUnreadWrapping(): void {
|
||||
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
|
||||
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
|
||||
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0);
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
|
||||
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
|
||||
}
|
||||
|
||||
it("should hide wrapping filters and show chevron", () => {
|
||||
render(<NarrowContainer />);
|
||||
mockUnreadWrapping();
|
||||
|
||||
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand and collapse filter list with chevron button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NarrowContainer />);
|
||||
mockUnreadWrapping();
|
||||
|
||||
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
|
||||
expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse filter list" }));
|
||||
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should move active filter to front when collapsed and wrapping", () => {
|
||||
render(<NarrowWithActiveWrappingFilter />);
|
||||
mockUnreadWrapping();
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
|
||||
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "Unreads" }));
|
||||
});
|
||||
|
||||
it("should restore original filter order when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NarrowWithActiveWrappingFilter />);
|
||||
mockUnreadWrapping();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
|
||||
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "People" }));
|
||||
});
|
||||
|
||||
it("should handle resize from non-wrapping to wrapping", () => {
|
||||
render(<NarrowContainer />);
|
||||
mockFiltersNotWrapping();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull();
|
||||
|
||||
mockUnreadWrapping();
|
||||
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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, 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 FilterId } from "./useVisibleFilters";
|
||||
import styles from "./RoomListPrimaryFilters.module.css";
|
||||
|
||||
/**
|
||||
* Maps filter IDs to translated labels
|
||||
*/
|
||||
const filterIdToLabel = (filterId: FilterId): string => {
|
||||
switch (filterId) {
|
||||
case "unread":
|
||||
return _t("room_list|filters|unread");
|
||||
case "people":
|
||||
return _t("room_list|filters|people");
|
||||
case "rooms":
|
||||
return _t("room_list|filters|rooms");
|
||||
case "favourite":
|
||||
return _t("room_list|filters|favourite");
|
||||
case "mentions":
|
||||
return _t("room_list|filters|mentions");
|
||||
case "invites":
|
||||
return _t("room_list|filters|invites");
|
||||
case "low_priority":
|
||||
return _t("room_list|filters|low_priority");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for RoomListPrimaryFilters component
|
||||
*/
|
||||
export interface RoomListPrimaryFiltersProps {
|
||||
/** Array of filter IDs to display */
|
||||
filterIds: FilterId[];
|
||||
/** Currently active filter ID (if any) */
|
||||
activeFilterId?: FilterId;
|
||||
/** Callback when a filter is toggled */
|
||||
onToggleFilter: (filterId: FilterId) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary filters component for the room list.
|
||||
* Displays a collapsible list of filters with expand/collapse functionality.
|
||||
*/
|
||||
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
|
||||
filterIds,
|
||||
activeFilterId,
|
||||
onToggleFilter,
|
||||
}): JSX.Element | null => {
|
||||
const id = useId();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const {
|
||||
ref,
|
||||
isWrapping: displayChevron,
|
||||
wrappingIndex,
|
||||
} = useCollapseFilters<HTMLUListElement>(isExpanded, "wrapping");
|
||||
const visibleFilterIds = useVisibleFilters(filterIds, activeFilterId, 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={styles.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}
|
||||
>
|
||||
{visibleFilterIds.map((filterId, index) => (
|
||||
<ChatFilter
|
||||
key={`${filterId}-${index}`}
|
||||
role="option"
|
||||
selected={filterId === activeFilterId}
|
||||
onClick={() => onToggleFilter(filterId)}
|
||||
>
|
||||
{filterIdToLabel(filterId)}
|
||||
</ChatFilter>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders Default story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex roomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-controls="_r_0_"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand filter list"
|
||||
class="_icon-button_1215g_8 iconButton"
|
||||
data-kind="secondary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="flex list"
|
||||
id="_r_0_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Unreads
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
People
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Favourites
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Mentions
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8 wrapping"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Low priority
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowContainer story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
|
||||
>
|
||||
<div
|
||||
class="flex roomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-controls="_r_3_"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand filter list"
|
||||
class="_icon-button_1215g_8 iconButton"
|
||||
data-kind="secondary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="flex list"
|
||||
id="_r_3_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
People
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8 wrapping"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Unreads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowWithActiveWrappingFilter story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
|
||||
>
|
||||
<div
|
||||
class="flex roomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-controls="_r_4_"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand filter list"
|
||||
class="_icon-button_1215g_8 iconButton"
|
||||
data-kind="secondary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="flex list"
|
||||
id="_r_4_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
aria-selected="true"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Unreads
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
People
|
||||
</button>
|
||||
<button
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NoFilters story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex roomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="flex list"
|
||||
id="_r_2_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders PeopleSelected story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="flex roomListPrimaryFilters"
|
||||
data-testid="primary-filters"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
aria-controls="_r_1_"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand filter list"
|
||||
class="_icon-button_1215g_8 iconButton"
|
||||
data-kind="secondary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
aria-label="Room list filters"
|
||||
class="flex list"
|
||||
id="_r_1_"
|
||||
role="listbox"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Unreads
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="true"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
People
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Favourites
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Mentions
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="false"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
aria-selected="false"
|
||||
class="_chat-filter_5qdp0_8 wrapping"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
Low priority
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters";
|
||||
export { useCollapseFilters } from "./useCollapseFilters";
|
||||
export { useVisibleFilters } from "./useVisibleFilters";
|
||||
export type { FilterId } from "./useVisibleFilters";
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 { 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
|
||||
* @param wrappingClassName - the CSS class to apply to wrapping filters
|
||||
* @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,
|
||||
wrappingClassName: string,
|
||||
): {
|
||||
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;
|
||||
child.setAttribute("aria-hidden", "false");
|
||||
child.classList.remove(wrappingClassName);
|
||||
|
||||
// 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(wrappingClassName, 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, wrappingClassName]);
|
||||
|
||||
return { ref, isWrapping, wrappingIndex };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Standard filter identifiers that can be used across implementations.
|
||||
* These are stable keys - the view layer maps them to translated labels.
|
||||
*/
|
||||
export type FilterId = "unread" | "people" | "rooms" | "favourite" | "mentions" | "invites" | "low_priority";
|
||||
|
||||
/**
|
||||
* A hook to sort the filter IDs by active state.
|
||||
* The list is sorted if the active filter index is greater than or equal to the wrapping index.
|
||||
* If the wrapping index is -1, the filters are not sorted.
|
||||
*
|
||||
* @param filterIds - the list of filter IDs to sort.
|
||||
* @param activeFilterId - the currently active filter ID (if any).
|
||||
* @param wrappingIndex - the index of the first filter that is wrapping.
|
||||
*/
|
||||
export function useVisibleFilters(
|
||||
filterIds: FilterId[],
|
||||
activeFilterId: FilterId | undefined,
|
||||
wrappingIndex: number,
|
||||
): FilterId[] {
|
||||
// By default, the filters are not sorted
|
||||
const [sortedFilterIds, setSortedFilterIds] = useState(filterIds);
|
||||
|
||||
useEffect(() => {
|
||||
const activeIndex = activeFilterId ? filterIds.indexOf(activeFilterId) : -1;
|
||||
const isActiveFilterWrapping = activeIndex >= wrappingIndex;
|
||||
// If the active filter is not wrapping, we don't need to sort the filters
|
||||
if (!isActiveFilterWrapping || wrappingIndex === -1) {
|
||||
setSortedFilterIds(filterIds);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the filters with the active filter at first position
|
||||
setSortedFilterIds(
|
||||
filterIds.slice().sort((filterA, filterB) => {
|
||||
// If the filter is active, it should be at the top of the list
|
||||
if (filterA === activeFilterId && filterB !== activeFilterId) return -1;
|
||||
if (filterA !== activeFilterId && filterB === activeFilterId) return 1;
|
||||
// If both filters are active or not, keep their original order
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
}, [filterIds, activeFilterId, wrappingIndex]);
|
||||
|
||||
return sortedFilterIds;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 "./RoomListEmptyStateView.module.css";
|
||||
|
||||
/**
|
||||
* Props for RoomListEmptyStateView component
|
||||
*/
|
||||
export interface RoomListEmptyStateViewProps {
|
||||
/** 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 RoomListEmptyStateView: React.FC<RoomListEmptyStateViewProps> = ({ 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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 { Room } from "../RoomListItem/RoomListItem";
|
||||
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: Room) => 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),
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 { RoomListEmptyStateView } from "./RoomListEmptyStateView";
|
||||
import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView";
|
||||
import { type Room } 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: (room: Room) => 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 = <RoomListEmptyStateView vm={vm} />;
|
||||
} else {
|
||||
listBody = <VirtualizedRoomListView vm={vm} renderAvatar={renderAvatar} onKeyDown={onKeyDown} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<RoomListPrimaryFilters
|
||||
filterIds={snapshot.filterIds}
|
||||
activeFilterId={snapshot.activeFilterId}
|
||||
onToggleFilter={vm.onToggleFilter}
|
||||
/>
|
||||
</div>
|
||||
{listBody}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -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 { RoomListEmptyStateView } from "./RoomListEmptyStateView";
|
||||
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
|
||||
@@ -1,10 +1,14 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomList {
|
||||
/**
|
||||
* Room list container styles
|
||||
*/
|
||||
.roomList {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { Room } from "../RoomListItem/RoomListItem";
|
||||
import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView";
|
||||
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: Room) => 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" }}>
|
||||
<VirtualizedRoomListView 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/VirtualizedRoomListView",
|
||||
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,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4",
|
||||
},
|
||||
},
|
||||
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 "./VirtualizedRoomListView.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("<VirtualizedRoomListView />", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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 type { Room } from "../RoomListItem/RoomListItem";
|
||||
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 VirtualizedRoomListView component
|
||||
*/
|
||||
export interface VirtualizedRoomListViewProps {
|
||||
/**
|
||||
* 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: Room) => 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
|
||||
* <VirtualizedRoomListView vm={roomListViewModel} renderAvatar={(room) => <Avatar room={room} />} />
|
||||
* ```
|
||||
*/
|
||||
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { VirtualizedRoomListView } from "./VirtualizedRoomListView";
|
||||
export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";
|
||||
138
packages/shared-components/src/room-list/story-mocks.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 { Room } from "./RoomListItem/RoomListItem";
|
||||
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: Room): React.ReactElement => {
|
||||
// Cast to any to access properties - in real usage, the room object from the SDK will have these
|
||||
return mockAvatar((room as any)?.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`);
|
||||
@@ -1,9 +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.
|
||||
*/
|
||||
* 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, { useRef, type JSX, useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
|
||||
@@ -95,6 +95,19 @@ export interface IVirtualizedListProps<Item, Context> extends Omit<
|
||||
* @returns
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
/**
|
||||
* Optional total count of items (for virtualization with partial data loading).
|
||||
* If provided, this will be used instead of items.length for the total count.
|
||||
*/
|
||||
totalCount?: number;
|
||||
|
||||
/**
|
||||
* Optional callback when the visible range of items changes.
|
||||
* Useful for loading data on-demand as the user scrolls.
|
||||
* @param range - The new visible range with startIndex and endIndex
|
||||
*/
|
||||
rangeChanged?: (range: ListRange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +126,17 @@ export type ScrollIntoViewOnChange<Item, Context = any> = NonNullable<
|
||||
*/
|
||||
export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProps<Item, Context>): React.ReactElement {
|
||||
// Extract our custom props to avoid conflicts with Virtuoso props
|
||||
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
|
||||
const {
|
||||
items,
|
||||
getItemComponent,
|
||||
isItemFocusable,
|
||||
getItemKey,
|
||||
context,
|
||||
onKeyDown,
|
||||
totalCount,
|
||||
rangeChanged,
|
||||
...virtuosoProps
|
||||
} = props;
|
||||
/** Reference to the Virtuoso component for programmatic scrolling */
|
||||
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
|
||||
/** Reference to the DOM element containing the virtualized list */
|
||||
@@ -324,6 +347,15 @@ export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProp
|
||||
[tabIndexKey, isFocused, props.context],
|
||||
);
|
||||
|
||||
// Combine internal range tracking with optional external callback
|
||||
const handleRangeChanged = useCallback(
|
||||
(range: ListRange) => {
|
||||
setVisibleRange(range);
|
||||
rangeChanged?.(range);
|
||||
},
|
||||
[rangeChanged],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
// note that either the container of direct children must be focusable to be axe
|
||||
@@ -334,10 +366,11 @@ export function VirtualizedList<Item, Context = any>(props: IVirtualizedListProp
|
||||
scrollerRef={scrollerRef}
|
||||
onKeyDown={keyDownCallback}
|
||||
context={listContext}
|
||||
rangeChanged={setVisibleRange}
|
||||
rangeChanged={handleRangeChanged}
|
||||
// virtuoso errors internally if you pass undefined.
|
||||
overscan={props.overscan || 0}
|
||||
data={props.items}
|
||||
totalCount={totalCount}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
itemContent={getItemComponentInternal}
|
||||
|
||||
@@ -12,5 +12,5 @@ export * from "./ViewModelSubscriptions";
|
||||
export type * from "./ViewModel";
|
||||
export * from "./MockViewModel";
|
||||
export * from "./useCreateAutoDisposedViewModel";
|
||||
export * from "./useViewModel";
|
||||
export * from "./useMockedViewModel";
|
||||
export * from "./useViewModel";
|
||||
|
||||
@@ -18,19 +18,28 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
util,
|
||||
msg,
|
||||
page,
|
||||
app,
|
||||
bot,
|
||||
}) => {
|
||||
// Create a third room to navigate to
|
||||
const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] });
|
||||
await bot.awaitRoomMembership(room3Id);
|
||||
const room3 = { name: "Room Gamma", roomId: room3Id };
|
||||
|
||||
await util.goTo(room2);
|
||||
|
||||
// Display the unread first room
|
||||
await util.receiveMessages(room2, ["Msg2"]);
|
||||
await util.receiveMessages(room1, ["Msg1"]);
|
||||
await page.reload();
|
||||
|
||||
// switch rooms so they can re-order in the list
|
||||
await util.goTo(room1);
|
||||
// Switch to room3 so neither room1 nor room2 is selected/sticky
|
||||
// This allows them to reorder based on activity
|
||||
await util.goTo(room3);
|
||||
|
||||
// Room 1 has an unread message and should be displayed first
|
||||
// (as the default is to sort by activity)
|
||||
await util.assertRoomListOrder([room1, room2]);
|
||||
await util.assertRoomListOrder([room1, room2, room3]);
|
||||
});
|
||||
|
||||
test("Rooms with unread threads appear at the top of room list with default 'activity' order", async ({
|
||||
@@ -38,18 +47,29 @@ test.describe("Read receipts", { tag: "@mergequeue" }, () => {
|
||||
roomBeta: room2,
|
||||
util,
|
||||
msg,
|
||||
app,
|
||||
bot,
|
||||
}) => {
|
||||
// Create a third room to navigate to
|
||||
const room3Id = await app.client.createRoom({ name: "Room Gamma", invite: [bot.credentials.userId] });
|
||||
await bot.awaitRoomMembership(room3Id);
|
||||
const room3 = { name: "Room Gamma", roomId: room3Id };
|
||||
|
||||
await util.goTo(room2);
|
||||
await util.receiveMessages(room1, ["Msg1"]);
|
||||
await util.receiveMessages(room2, ["Msg2"]);
|
||||
await util.markAsRead(room1);
|
||||
await util.assertRead(room1);
|
||||
|
||||
// Display the unread first room
|
||||
// Display the unread first room (room1 moves above room2 as it has an unread thread)
|
||||
await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]);
|
||||
await util.saveAndReload();
|
||||
|
||||
// Switch to room3 so neither room1 nor room2 is selected/sticky
|
||||
await util.goTo(room3);
|
||||
|
||||
// Room 1 has an unread message and should be displayed first
|
||||
await util.assertRoomListOrder([room1, room2]);
|
||||
await util.assertRoomListOrder([room1, room2, room3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const test = base.extend<{
|
||||
|
||||
test.describe("Sliding Sync", () => {
|
||||
const checkOrder = async (wantOrder: string[], page: Page) => {
|
||||
await expect(page.getByTestId("room-list").locator(".mx_RoomListItemView_text")).toHaveText(wantOrder);
|
||||
await expect(page.getByTestId("room-list").getByTestId("room-name")).toHaveText(wantOrder);
|
||||
};
|
||||
|
||||
const bumpRoom = async (roomId: string, app: ElementAppPage) => {
|
||||
|
||||
@@ -263,14 +263,7 @@
|
||||
@import "./views/right_panel/_VerificationPanel.pcss";
|
||||
@import "./views/right_panel/_WidgetCard.pcss";
|
||||
@import "./views/room_settings/_AliasSettings.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomList.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss";
|
||||
@import "./views/rooms/_AppsDrawer.pcss";
|
||||
@import "./views/rooms/_Autocomplete.pcss";
|
||||
@import "./views/rooms/_AuxPanel.pcss";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_EmptyRoomList_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%;
|
||||
|
||||
.mx_EmptyRoomList_GenericPlaceholder_title {
|
||||
font: var(--cpd-font-body-lg-semibold);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mx_EmptyRoomList_GenericPlaceholder_description {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mx_EmptyRoomList_DefaultPlaceholder {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListItemMenuView {
|
||||
svg {
|
||||
fill: var(--cpd-color-icon-primary);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* 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 RoomListItemView has the following structure:
|
||||
* button--------------------------------------------------|
|
||||
* | <-12px-> container------------------------------------|
|
||||
* | | room avatar <-8px-> content----------------|
|
||||
* | | | room_name <- 20px ->|
|
||||
* | | | --------------------| <-- border
|
||||
* |-------------------------------------------------------|
|
||||
*/
|
||||
.mx_RoomListItemView {
|
||||
/* Remove button default style */
|
||||
color: inherit;
|
||||
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);
|
||||
|
||||
/* Hide the menu by default */
|
||||
.mx_RoomListItemView_menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
/* When the context menu is opened */
|
||||
&[data-state="open"],
|
||||
/* When the options and notifications menu are opened */
|
||||
&:has(.mx_RoomListItemMenuView > button[data-state="open"]) {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
|
||||
.mx_RoomListItemView_menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.mx_RoomListItemView_has_menu {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
.mx_RoomListItemView_content {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
/* When the menu is visible, hide the notification decoration to avoid clutter */
|
||||
.mx_RoomListItemView_notificationDecoration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_content {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
/* The border is only under the room name and the future hover menu */
|
||||
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding-right: var(--cpd-space-5x);
|
||||
|
||||
.mx_RoomListItemView_text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_roomName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_messagePreview {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_selected {
|
||||
background-color: var(--cpd-color-bg-action-secondary-pressed);
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListPrimaryFilters {
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
|
||||
.mx_RoomListPrimaryFilters_wrapping {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_list {
|
||||
/**
|
||||
* The InteractionObserver needs the height to be set to work properly.
|
||||
*/
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton {
|
||||
svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListSecondaryFilters {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
margin: var(--cpd-space-2x);
|
||||
margin-left: var(--cpd-space-1x);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RoomListSkeleton {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
height: 100%;
|
||||
|
||||
&::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("/res/img/element-icons/roomlist/room-list-item-skeleton.svg");
|
||||
}
|
||||
}
|
||||