mirror of
https://github.com/vector-im/element-web.git
synced 2025-12-25 19:21:18 +01:00
Fix errors
Migrate RoomList
This commit is contained in:
parent
e7eeb98c9c
commit
fa981e37c4
9
packages/shared-components/jest-sonar.xml
Normal file
9
packages/shared-components/jest-sonar.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<testExecutions version="1">
|
||||
<file path="/Users/davidlangley/dev/element-web/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx">
|
||||
<testCase name="RoomListHeader renders title" duration="153"/>
|
||||
<testCase name="RoomListHeader renders title with action" duration="7"/>
|
||||
<testCase name="RoomListHeader renders action buttons" duration="6"/>
|
||||
<testCase name="RoomListHeader truncates long titles with title attribute" duration="15"/>
|
||||
<testCase name="RoomListHeader renders data-testid attribute" duration="5"/>
|
||||
</file>
|
||||
</testExecutions>
|
||||
@ -52,6 +52,7 @@
|
||||
"matrix-web-i18n": "^3.4.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
"react-virtuoso": "^4.15.0",
|
||||
"temporal-polyfill": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -13,12 +13,21 @@ export * from "./audio/SeekBar";
|
||||
export * from "./avatar/AvatarWithDetails";
|
||||
export * from "./event-tiles/TextualEventView";
|
||||
export * from "./message-body/MediaBody";
|
||||
export * from "./notifications/NotificationDecoration";
|
||||
export * from "./pill-input/Pill";
|
||||
export * from "./pill-input/PillInput";
|
||||
export * from "./rich-list/RichItem";
|
||||
export * from "./rich-list/RichList";
|
||||
export * from "./room-list/RoomList";
|
||||
export * from "./room-list/RoomListItem";
|
||||
export * from "./room-list/RoomListPanel";
|
||||
export * from "./room-list/RoomListSearch";
|
||||
export * from "./room-list/RoomListHeader";
|
||||
export * from "./room-list/RoomListView";
|
||||
export * from "./room-list/RoomListPrimaryFilters";
|
||||
export * from "./utils/Box";
|
||||
export * from "./utils/Flex";
|
||||
export * from "./utils/ListView";
|
||||
|
||||
// Utils
|
||||
export * from "./utils/i18n";
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
|
||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
|
||||
/**
|
||||
* ViewModel representing the notification state for a room or item
|
||||
*/
|
||||
export interface NotificationDecorationViewModel {
|
||||
/** Whether there is any notification or activity to display */
|
||||
hasAnyNotificationOrActivity: boolean;
|
||||
/** Whether there's an unsent message */
|
||||
isUnsentMessage: boolean;
|
||||
/** Whether the user is invited to the room */
|
||||
invited: boolean;
|
||||
/** Whether the notification is a mention */
|
||||
isMention: boolean;
|
||||
/** Whether there's activity (not a full notification) */
|
||||
isActivityNotification: boolean;
|
||||
/** Whether there's a notification (not just activity) */
|
||||
isNotification: boolean;
|
||||
/** Notification count */
|
||||
count: number;
|
||||
/** Whether notifications are muted */
|
||||
muted: boolean;
|
||||
/** Optional call type indicator */
|
||||
callType?: "video" | "voice";
|
||||
}
|
||||
|
||||
export interface NotificationDecorationProps {
|
||||
/** ViewModel containing notification state */
|
||||
viewModel: NotificationDecorationViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders notification badges and indicators for rooms/items
|
||||
*/
|
||||
export const NotificationDecoration: React.FC<NotificationDecorationProps> = ({ viewModel }) => {
|
||||
// Don't render anything if there's nothing to show
|
||||
if (!viewModel.hasAnyNotificationOrActivity && !viewModel.muted && !viewModel.callType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="center" gap="var(--cpd-space-1x)" data-testid="notification-decoration">
|
||||
{viewModel.isUnsentMessage && (
|
||||
<ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />
|
||||
)}
|
||||
{viewModel.callType === "video" && (
|
||||
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{viewModel.callType === "voice" && (
|
||||
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{viewModel.invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{viewModel.isMention && (
|
||||
<MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{(viewModel.isMention || viewModel.isNotification) && <UnreadCounter count={viewModel.count || null} />}
|
||||
{viewModel.isActivityNotification && <Unread />}
|
||||
{viewModel.muted && (
|
||||
<NotificationOffIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { NotificationDecoration } from "./NotificationDecoration";
|
||||
export type { NotificationDecorationProps, NotificationDecorationViewModel } from "./NotificationDecoration";
|
||||
21
packages/shared-components/src/notifications/RoomNotifs.ts
Normal file
21
packages/shared-components/src/notifications/RoomNotifs.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Notification state for a room.
|
||||
* Matches the element-web RoomNotifState enum.
|
||||
*/
|
||||
export enum RoomNotifState {
|
||||
/** All messages (default) */
|
||||
AllMessages = "all_messages",
|
||||
/** All messages with sound */
|
||||
AllMessagesLoud = "all_messages_loud",
|
||||
/** Only mentions and keywords */
|
||||
MentionsOnly = "mentions_only",
|
||||
/** Muted */
|
||||
Mute = "mute",
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Room list container styles
|
||||
* The actual room items are styled by the consumer (element-web)
|
||||
*/
|
||||
.roomList {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
|
||||
import type { RoomListItemViewModel } from "../RoomListItem";
|
||||
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
|
||||
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
|
||||
import { type RoomNotifState } from "../../notifications/RoomNotifs";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
// Mock avatar component
|
||||
const mockAvatar = (name: string): React.ReactElement => (
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#0dbd8b",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Generate mock rooms with ViewModels
|
||||
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
|
||||
const mockNotificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const mockMenuViewModel: RoomListItemMenuViewModel = {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
canMarkAsRead: true,
|
||||
canMarkAsUnread: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationAllMessageLoud: false,
|
||||
isNotificationMentionOnly: false,
|
||||
isNotificationMute: false,
|
||||
markAsRead: () => console.log("Mark as read"),
|
||||
markAsUnread: () => console.log("Mark as unread"),
|
||||
toggleFavorite: () => console.log("Toggle favorite"),
|
||||
toggleLowPriority: () => console.log("Toggle low priority"),
|
||||
invite: () => console.log("Invite"),
|
||||
copyRoomLink: () => console.log("Copy room link"),
|
||||
leaveRoom: () => console.log("Leave room"),
|
||||
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
|
||||
};
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const hasUnread = Math.random() > 0.7;
|
||||
const unreadCount = hasUnread ? Math.floor(Math.random() * 10) : 0;
|
||||
const hasNotification = Math.random() > 0.8;
|
||||
const isMention = Math.random() > 0.9;
|
||||
|
||||
const notificationViewModel: NotificationDecorationViewModel = hasUnread
|
||||
? {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention,
|
||||
isActivityNotification: !hasNotification,
|
||||
isNotification: hasNotification,
|
||||
count: unreadCount,
|
||||
muted: false,
|
||||
}
|
||||
: mockNotificationViewModel;
|
||||
|
||||
return {
|
||||
id: `!room${i}:server`,
|
||||
name: `Room ${i + 1}`,
|
||||
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
|
||||
a11yLabel: unreadCount > 0 ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
|
||||
isBold: unreadCount > 0,
|
||||
messagePreview: undefined,
|
||||
notificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mockRoomsResult: RoomsResult = {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: generateMockRooms(50),
|
||||
};
|
||||
|
||||
const mockViewModel: RoomListViewModel = {
|
||||
roomsResult: mockRoomsResult,
|
||||
activeRoomIndex: undefined,
|
||||
onKeyDown: undefined,
|
||||
};
|
||||
|
||||
const renderAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => {
|
||||
return mockAvatar(roomViewModel.name);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomList",
|
||||
component: RoomList,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px", border: "1px solid #ccc" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
viewModel: mockViewModel,
|
||||
renderAvatar,
|
||||
},
|
||||
} satisfies Meta<typeof RoomList>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...mockViewModel,
|
||||
activeRoomIndex: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallList: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...mockViewModel,
|
||||
roomsResult: {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: generateMockRooms(5),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeList: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...mockViewModel,
|
||||
roomsResult: {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: generateMockRooms(200),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyList: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...mockViewModel,
|
||||
roomsResult: {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { RoomList, type RoomListViewModel, type RoomsResult } from "./RoomList";
|
||||
import type { RoomListItemViewModel } from "../RoomListItem";
|
||||
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
|
||||
import type { RoomListItemMenuViewModel } from "../RoomListItem/RoomListItemMenuViewModel";
|
||||
|
||||
describe("RoomList", () => {
|
||||
const mockNotificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const mockMenuViewModel: RoomListItemMenuViewModel = {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
canMarkAsRead: true,
|
||||
canMarkAsUnread: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationAllMessageLoud: false,
|
||||
isNotificationMentionOnly: false,
|
||||
isNotificationMute: false,
|
||||
markAsRead: jest.fn(),
|
||||
markAsUnread: jest.fn(),
|
||||
toggleFavorite: jest.fn(),
|
||||
toggleLowPriority: jest.fn(),
|
||||
invite: jest.fn(),
|
||||
copyRoomLink: jest.fn(),
|
||||
leaveRoom: jest.fn(),
|
||||
setRoomNotifState: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRooms: RoomListItemViewModel[] = [
|
||||
{
|
||||
id: "!room1:server",
|
||||
name: "Room 1",
|
||||
openRoom: jest.fn(),
|
||||
a11yLabel: "Room 1",
|
||||
isBold: false,
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
},
|
||||
{
|
||||
id: "!room2:server",
|
||||
name: "Room 2",
|
||||
openRoom: jest.fn(),
|
||||
a11yLabel: "Room 2",
|
||||
isBold: false,
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
},
|
||||
{
|
||||
id: "!room3:server",
|
||||
name: "Room 3",
|
||||
openRoom: jest.fn(),
|
||||
a11yLabel: "Room 3",
|
||||
isBold: false,
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
},
|
||||
];
|
||||
|
||||
const mockRoomsResult: RoomsResult = {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: mockRooms,
|
||||
};
|
||||
|
||||
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
|
||||
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
|
||||
));
|
||||
|
||||
const mockViewModel: RoomListViewModel = {
|
||||
roomsResult: mockRoomsResult,
|
||||
activeRoomIndex: undefined,
|
||||
onKeyDown: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRenderAvatar.mockClear();
|
||||
});
|
||||
|
||||
it("renders the room list with correct aria attributes", () => {
|
||||
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(listbox).toHaveAttribute("data-testid", "room-list");
|
||||
});
|
||||
|
||||
it("renders with correct aria-label", () => {
|
||||
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(listbox).toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("calls renderAvatar for each room", () => {
|
||||
render(<RoomList viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
// renderAvatar should be called for visible rooms (virtualization means not all may render immediately)
|
||||
expect(mockRenderAvatar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty room list", () => {
|
||||
const emptyResult: RoomsResult = {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: [],
|
||||
};
|
||||
|
||||
const emptyViewModel: RoomListViewModel = {
|
||||
...mockViewModel,
|
||||
roomsResult: emptyResult,
|
||||
};
|
||||
|
||||
render(<RoomList viewModel={emptyViewModel} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes activeRoomIndex correctly", () => {
|
||||
const vmWithActive: RoomListViewModel = {
|
||||
...mockViewModel,
|
||||
activeRoomIndex: 1,
|
||||
};
|
||||
|
||||
render(<RoomList viewModel={vmWithActive} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
// Component should render with active index set
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles keyboard events via onKeyDown callback", () => {
|
||||
const onKeyDown = jest.fn();
|
||||
const vmWithKeyDown: RoomListViewModel = {
|
||||
...mockViewModel,
|
||||
onKeyDown,
|
||||
};
|
||||
|
||||
render(<RoomList viewModel={vmWithKeyDown} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
listbox.focus();
|
||||
|
||||
// Fire a keyboard event
|
||||
const event = new KeyboardEvent("keydown", { key: "ArrowDown", code: "ArrowDown" });
|
||||
listbox.dispatchEvent(event);
|
||||
|
||||
// onKeyDown should be called
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
183
packages/shared-components/src/room-list/RoomList/RoomList.tsx
Normal file
183
packages/shared-components/src/room-list/RoomList/RoomList.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, type JSX, type ReactNode } from "react";
|
||||
import { type ScrollIntoViewLocation } from "react-virtuoso";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { ListView, type ListContext } from "../../utils/ListView";
|
||||
import { RoomListItem, type RoomListItemViewModel } from "../RoomListItem";
|
||||
|
||||
/**
|
||||
* Filter key type - opaque string type for filter identifiers
|
||||
*/
|
||||
export type FilterKey = string;
|
||||
|
||||
/**
|
||||
* Represents the result of a room query
|
||||
*/
|
||||
export interface RoomsResult {
|
||||
/** The ID of the current space */
|
||||
spaceId: string;
|
||||
/** Active filter keys */
|
||||
filterKeys: FilterKey[] | undefined;
|
||||
/** Array of room item view models */
|
||||
rooms: RoomListItemViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomList
|
||||
*/
|
||||
export interface RoomListViewModel {
|
||||
/** The rooms result containing the list of rooms */
|
||||
roomsResult: RoomsResult;
|
||||
/** Optional active room index */
|
||||
activeRoomIndex?: number;
|
||||
/** Optional keyboard event handler */
|
||||
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the RoomList component
|
||||
*/
|
||||
export interface RoomListProps {
|
||||
/**
|
||||
* The view model containing room list data
|
||||
*/
|
||||
viewModel: RoomListViewModel;
|
||||
|
||||
/**
|
||||
* Render function for room avatar
|
||||
* @param roomViewModel - The room item view model
|
||||
*/
|
||||
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
|
||||
}
|
||||
|
||||
/** Height of a single room list item in pixels */
|
||||
const ROOM_LIST_ITEM_HEIGHT = 48;
|
||||
|
||||
/**
|
||||
* Amount to extend the top and bottom of the viewport by.
|
||||
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space
|
||||
* when using the mouse wheel, and the trackpad scrolling at a slow to moderate speed where you
|
||||
* can still see/read the content. Using the trackpad to sling through a large percentage of the
|
||||
* list quickly will still show blank space. We would likely need to simplify the item content to
|
||||
* improve this case.
|
||||
*/
|
||||
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
|
||||
|
||||
/**
|
||||
* A virtualized list of rooms.
|
||||
* This component provides efficient rendering of large room lists using virtualization,
|
||||
* and renders RoomListItem components for each room.
|
||||
*/
|
||||
export function RoomList({ viewModel, renderAvatar }: RoomListProps): JSX.Element {
|
||||
const { roomsResult, activeRoomIndex, onKeyDown } = viewModel;
|
||||
const lastSpaceId = useRef<string | undefined>(undefined);
|
||||
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
|
||||
const roomCount = roomsResult.rooms.length;
|
||||
|
||||
/**
|
||||
* Get the item component for a specific index
|
||||
*/
|
||||
const getItemComponent = useCallback(
|
||||
(
|
||||
index: number,
|
||||
item: RoomListItemViewModel,
|
||||
context: ListContext<{
|
||||
spaceId: string;
|
||||
filterKeys: FilterKey[] | undefined;
|
||||
}>,
|
||||
onFocus: (item: RoomListItemViewModel, e: React.FocusEvent) => void,
|
||||
): JSX.Element => {
|
||||
const itemKey = item.id;
|
||||
const isRovingItem = itemKey === context.tabIndexKey;
|
||||
const isFocused = isRovingItem && context.focused;
|
||||
const isSelected = activeRoomIndex === index;
|
||||
|
||||
return (
|
||||
<div key={itemKey}>
|
||||
<RoomListItem
|
||||
viewModel={item}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
onFocus={(e) => onFocus(item, e)}
|
||||
roomIndex={index}
|
||||
roomCount={roomCount}
|
||||
avatar={renderAvatar(item)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeRoomIndex, roomCount, renderAvatar],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the key for a room item
|
||||
*/
|
||||
const getItemKey = useCallback((item: RoomListItemViewModel): string => {
|
||||
return item.id;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Determine if we should scroll the active index into view
|
||||
* This happens when the space or filters change
|
||||
*/
|
||||
const scrollIntoViewOnChange = useCallback(
|
||||
(params: {
|
||||
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
|
||||
}): ScrollIntoViewLocation | null | undefined | false | void => {
|
||||
const { spaceId, filterKeys } = params.context.context;
|
||||
const shouldScrollIndexIntoView =
|
||||
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
|
||||
lastFilterKeys.current = filterKeys;
|
||||
lastSpaceId.current = spaceId;
|
||||
|
||||
if (shouldScrollIndexIntoView) {
|
||||
return {
|
||||
align: "start",
|
||||
index: activeRoomIndex || 0,
|
||||
behavior: "auto",
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[activeRoomIndex],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle keyboard events
|
||||
*/
|
||||
const keyDownCallback = useCallback(
|
||||
(ev: React.KeyboardEvent): void => {
|
||||
onKeyDown?.(ev);
|
||||
},
|
||||
[onKeyDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<ListView
|
||||
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
|
||||
scrollIntoViewOnChange={scrollIntoViewOnChange}
|
||||
initialTopMostItemIndex={activeRoomIndex}
|
||||
data-testid="room-list"
|
||||
role="listbox"
|
||||
aria-label={_t("room_list|list_title")}
|
||||
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
|
||||
items={roomsResult.rooms}
|
||||
getItemComponent={getItemComponent}
|
||||
getItemKey={getItemKey}
|
||||
isItemFocusable={() => true}
|
||||
onKeyDown={keyDownCallback}
|
||||
increaseViewportBy={{
|
||||
bottom: EXTENDED_VIEWPORT_HEIGHT,
|
||||
top: EXTENDED_VIEWPORT_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomList } from "./RoomList";
|
||||
export type { RoomListProps, RoomListViewModel, RoomsResult, FilterKey } from "./RoomList";
|
||||
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
|
||||
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
|
||||
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
/**
|
||||
* ViewModel for ComposeMenu
|
||||
*/
|
||||
export interface ComposeMenuViewModel {
|
||||
/** Whether the user can create rooms */
|
||||
canCreateRoom: boolean;
|
||||
/** Whether the user can create video rooms */
|
||||
canCreateVideoRoom: boolean;
|
||||
/** Create a chat room */
|
||||
createChatRoom: () => void;
|
||||
/** Create a room */
|
||||
createRoom: () => void;
|
||||
/** Create a video room */
|
||||
createVideoRoom: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ComposeMenu component
|
||||
*/
|
||||
export interface ComposeMenuProps {
|
||||
/** The view model containing menu data and callbacks */
|
||||
viewModel: ComposeMenuViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compose menu for the room list header.
|
||||
* Displays a dropdown menu with options to create new chats, rooms, and video rooms.
|
||||
*/
|
||||
export const ComposeMenu: React.FC<ComposeMenuProps> = ({ viewModel }): JSX.Element => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showTitle={false}
|
||||
title={_t("action|open_menu")}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton tooltip={_t("action|new_conversation")}>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
Icon={ChatIcon}
|
||||
label={_t("action|start_chat")}
|
||||
onSelect={viewModel.createChatRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{viewModel.canCreateRoom && (
|
||||
<MenuItem
|
||||
Icon={RoomIcon}
|
||||
label={_t("action|new_room")}
|
||||
onSelect={viewModel.createRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{viewModel.canCreateVideoRoom && (
|
||||
<MenuItem
|
||||
Icon={VideoCallIcon}
|
||||
label={_t("action|new_video_room")}
|
||||
onSelect={viewModel.createVideoRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.roomListHeader {
|
||||
flex: 0 0 60px;
|
||||
padding: 0 var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
all: unset;
|
||||
font: var(--cpd-font-heading-sm-semibold);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Styles for element-web specific components */
|
||||
.roomListHeader :global(.mx_SpaceMenu_button) svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.roomListHeader :global(.mx_SpaceMenu_button[aria-expanded="true"]) svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.roomListHeader :global(.mx_RoomListHeaderView_ReleaseAnnouncementAnchor) {
|
||||
display: inline-flex;
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RoomListHeader } from "./RoomListHeader";
|
||||
import { SortOption } from "./SortOptionsMenu";
|
||||
import type { SpaceMenuViewModel } from "./SpaceMenu";
|
||||
import type { ComposeMenuViewModel } from "./ComposeMenu";
|
||||
|
||||
const meta: Meta<typeof RoomListHeader> = {
|
||||
title: "Room List/RoomListHeader",
|
||||
component: RoomListHeader,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoomListHeader>;
|
||||
|
||||
const baseSortOptionsViewModel = {
|
||||
activeSortOption: SortOption.Activity,
|
||||
sort: (option: SortOption) => console.log("Sort by:", option),
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
title: "Home",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: () => console.log("Compose clicked"),
|
||||
sortOptionsMenuViewModel: baseSortOptionsViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSpaceMenu: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
title: "My Space",
|
||||
isSpace: true,
|
||||
spaceMenuViewModel: {
|
||||
title: "My Space",
|
||||
canInviteInSpace: true,
|
||||
canAccessSpaceSettings: true,
|
||||
openSpaceHome: () => console.log("Open space home"),
|
||||
inviteInSpace: () => console.log("Invite in space"),
|
||||
openSpacePreferences: () => console.log("Open space preferences"),
|
||||
openSpaceSettings: () => console.log("Open space settings"),
|
||||
} as SpaceMenuViewModel,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: () => console.log("Compose clicked"),
|
||||
sortOptionsMenuViewModel: baseSortOptionsViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithComposeMenu: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
title: "Home",
|
||||
isSpace: false,
|
||||
displayComposeMenu: true,
|
||||
composeMenuViewModel: {
|
||||
canCreateRoom: true,
|
||||
canCreateVideoRoom: true,
|
||||
createChatRoom: () => console.log("Create chat room"),
|
||||
createRoom: () => console.log("Create room"),
|
||||
createVideoRoom: () => console.log("Create video room"),
|
||||
} as ComposeMenuViewModel,
|
||||
sortOptionsMenuViewModel: baseSortOptionsViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FullHeader: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
title: "My Space",
|
||||
isSpace: true,
|
||||
spaceMenuViewModel: {
|
||||
title: "My Space",
|
||||
canInviteInSpace: true,
|
||||
canAccessSpaceSettings: true,
|
||||
openSpaceHome: () => console.log("Open space home"),
|
||||
inviteInSpace: () => console.log("Invite in space"),
|
||||
openSpacePreferences: () => console.log("Open space preferences"),
|
||||
openSpaceSettings: () => console.log("Open space settings"),
|
||||
} as SpaceMenuViewModel,
|
||||
displayComposeMenu: true,
|
||||
composeMenuViewModel: {
|
||||
canCreateRoom: true,
|
||||
canCreateVideoRoom: true,
|
||||
createChatRoom: () => console.log("Create chat room"),
|
||||
createRoom: () => console.log("Create room"),
|
||||
createVideoRoom: () => console.log("Create video room"),
|
||||
} as ComposeMenuViewModel,
|
||||
sortOptionsMenuViewModel: baseSortOptionsViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongTitle: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
|
||||
isSpace: true,
|
||||
spaceMenuViewModel: {
|
||||
title: "This is a very long space name that should be truncated with ellipsis when it overflows",
|
||||
canInviteInSpace: true,
|
||||
canAccessSpaceSettings: true,
|
||||
openSpaceHome: () => console.log("Open space home"),
|
||||
inviteInSpace: () => console.log("Invite in space"),
|
||||
openSpacePreferences: () => console.log("Open space preferences"),
|
||||
openSpaceSettings: () => console.log("Open space settings"),
|
||||
} as SpaceMenuViewModel,
|
||||
displayComposeMenu: true,
|
||||
composeMenuViewModel: {
|
||||
canCreateRoom: true,
|
||||
canCreateVideoRoom: true,
|
||||
createChatRoom: () => console.log("Create chat room"),
|
||||
createRoom: () => console.log("Create room"),
|
||||
createVideoRoom: () => console.log("Create video room"),
|
||||
} as ComposeMenuViewModel,
|
||||
sortOptionsMenuViewModel: baseSortOptionsViewModel,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import { RoomListHeader, type RoomListHeaderViewModel } from "./RoomListHeader";
|
||||
import type { SpaceMenuViewModel } from "./SpaceMenu";
|
||||
import type { ComposeMenuViewModel } from "./ComposeMenu";
|
||||
import type { SortOptionsMenuViewModel } from "./SortOptionsMenu";
|
||||
import { SortOption } from "./SortOptionsMenu";
|
||||
|
||||
describe("RoomListHeader", () => {
|
||||
const mockSortOptionsViewModel: SortOptionsMenuViewModel = {
|
||||
activeSortOption: SortOption.Activity,
|
||||
sort: jest.fn(),
|
||||
};
|
||||
|
||||
it("renders title", () => {
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByText("My Space")).toBeInTheDocument();
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders space menu when isSpace is true", () => {
|
||||
const mockSpaceMenuViewModel: SpaceMenuViewModel = {
|
||||
title: "My Space",
|
||||
canInviteInSpace: true,
|
||||
canAccessSpaceSettings: true,
|
||||
openSpaceHome: jest.fn(),
|
||||
inviteInSpace: jest.fn(),
|
||||
openSpacePreferences: jest.fn(),
|
||||
openSpaceSettings: jest.fn(),
|
||||
};
|
||||
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: true,
|
||||
spaceMenuViewModel: mockSpaceMenuViewModel,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByText("My Space")).toBeInTheDocument();
|
||||
// Space menu chevron button should be present
|
||||
expect(screen.getByLabelText("Open space menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compose menu when displayComposeMenu is true", () => {
|
||||
const mockComposeMenuViewModel: ComposeMenuViewModel = {
|
||||
canCreateRoom: true,
|
||||
canCreateVideoRoom: true,
|
||||
createChatRoom: jest.fn(),
|
||||
createRoom: jest.fn(),
|
||||
createVideoRoom: jest.fn(),
|
||||
};
|
||||
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: false,
|
||||
displayComposeMenu: true,
|
||||
composeMenuViewModel: mockComposeMenuViewModel,
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
// Compose button should be present
|
||||
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compose icon button when displayComposeMenu is false", () => {
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
// Compose icon button should be present
|
||||
expect(screen.getByLabelText("New conversation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sort options menu", () => {
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
// Sort options menu trigger should be present
|
||||
expect(screen.getByLabelText("Room options")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates long titles with title attribute", () => {
|
||||
const longTitle = "This is a very long space name that should be truncated";
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: longTitle,
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
const h1 = screen.getByRole("heading", { level: 1 });
|
||||
expect(h1).toHaveAttribute("title", longTitle);
|
||||
expect(h1).toHaveTextContent(longTitle);
|
||||
});
|
||||
|
||||
it("renders data-testid attribute", () => {
|
||||
const viewModel: RoomListHeaderViewModel = {
|
||||
title: "My Space",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: mockSortOptionsViewModel,
|
||||
};
|
||||
|
||||
render(<RoomListHeader viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByTestId("room-list-header")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
import ComposeIcon from "@vector-im/compound-design-tokens/assets/web/icons/compose";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { SpaceMenu, type SpaceMenuViewModel } from "./SpaceMenu";
|
||||
import { ComposeMenu, type ComposeMenuViewModel } from "./ComposeMenu";
|
||||
import { SortOptionsMenu, type SortOptionsMenuViewModel } from "./SortOptionsMenu";
|
||||
import styles from "./RoomListHeader.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListHeader
|
||||
*/
|
||||
export interface RoomListHeaderViewModel {
|
||||
/** The title to display in the header */
|
||||
title: string;
|
||||
/** Whether to display the space menu (true if there is an active space) */
|
||||
isSpace: boolean;
|
||||
/** Space menu view model (only used if isSpace is true) */
|
||||
spaceMenuViewModel?: SpaceMenuViewModel;
|
||||
/** Whether to display the compose menu */
|
||||
displayComposeMenu: boolean;
|
||||
/** Compose menu view model (only used if displayComposeMenu is true) */
|
||||
composeMenuViewModel?: ComposeMenuViewModel;
|
||||
/** Callback when compose button is clicked (only used if displayComposeMenu is false) */
|
||||
onComposeClick?: () => void;
|
||||
/** Sort options menu view model */
|
||||
sortOptionsMenuViewModel: SortOptionsMenuViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListHeader component
|
||||
*/
|
||||
export interface RoomListHeaderProps {
|
||||
/** The view model containing header data */
|
||||
viewModel: RoomListHeaderViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A presentational header component for the room list.
|
||||
* Displays a title with optional space menu, sort options, and compose actions.
|
||||
*/
|
||||
export const RoomListHeader: React.FC<RoomListHeaderProps> = ({ viewModel }): JSX.Element => {
|
||||
return (
|
||||
<Flex
|
||||
as="header"
|
||||
className={styles.roomListHeader}
|
||||
aria-label={_t("room|context_menu|title")}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
data-testid="room-list-header"
|
||||
>
|
||||
<Flex className={styles.title} align="center" gap="var(--cpd-space-1x)">
|
||||
<h1 title={viewModel.title}>{viewModel.title}</h1>
|
||||
{viewModel.isSpace && viewModel.spaceMenuViewModel && (
|
||||
<SpaceMenu viewModel={viewModel.spaceMenuViewModel} />
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center" gap="var(--cpd-space-2x)">
|
||||
<SortOptionsMenu viewModel={viewModel.sortOptionsMenuViewModel} />
|
||||
{viewModel.displayComposeMenu && viewModel.composeMenuViewModel ? (
|
||||
<ComposeMenu viewModel={viewModel.composeMenuViewModel} />
|
||||
) : (
|
||||
<IconButton onClick={viewModel.onComposeClick} tooltip={_t("action|new_conversation")}>
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuTitle, Tooltip, RadioMenuItem } from "@vector-im/compound-web";
|
||||
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
/**
|
||||
* Sort option enum
|
||||
*/
|
||||
export enum SortOption {
|
||||
Activity = "activity",
|
||||
AToZ = "atoz",
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for SortOptionsMenu
|
||||
*/
|
||||
export interface SortOptionsMenuViewModel {
|
||||
/** The currently active sort option */
|
||||
activeSortOption: SortOption;
|
||||
/** Change the sort order of the room-list */
|
||||
sort: (option: SortOption) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for SortOptionsMenu component
|
||||
*/
|
||||
export interface SortOptionsMenuProps {
|
||||
/** The view model containing menu data and callbacks */
|
||||
viewModel: SortOptionsMenuViewModel;
|
||||
}
|
||||
|
||||
const MenuTrigger = (props: React.ComponentProps<typeof IconButton>): JSX.Element => (
|
||||
<Tooltip label={_t("room_list|room_options")}>
|
||||
<IconButton aria-label={_t("room_list|room_options")} {...props}>
|
||||
<OverflowHorizontalIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
/**
|
||||
* The sort options menu for the room list header.
|
||||
* Displays a dropdown menu with options to sort rooms by activity or alphabetically.
|
||||
*/
|
||||
export const SortOptionsMenu: React.FC<SortOptionsMenuProps> = ({ viewModel }): JSX.Element => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onActivitySelected = useCallback(() => {
|
||||
viewModel.sort(SortOption.Activity);
|
||||
}, [viewModel]);
|
||||
|
||||
const onAtoZSelected = useCallback(() => {
|
||||
viewModel.sort(SortOption.AToZ);
|
||||
}, [viewModel]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|room_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={<MenuTrigger />}
|
||||
>
|
||||
<MenuTitle title={_t("room_list|sort")} />
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|activity")}
|
||||
checked={viewModel.activeSortOption === SortOption.Activity}
|
||||
onSelect={onActivitySelected}
|
||||
/>
|
||||
<RadioMenuItem
|
||||
label={_t("room_list|sort_type|atoz")}
|
||||
checked={viewModel.activeSortOption === SortOption.AToZ}
|
||||
onSelect={onAtoZSelected}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, type JSX } from "react";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
|
||||
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
|
||||
/**
|
||||
* ViewModel for SpaceMenu
|
||||
*/
|
||||
export interface SpaceMenuViewModel {
|
||||
/** The title of the space */
|
||||
title: string;
|
||||
/** Whether the user can invite in the space */
|
||||
canInviteInSpace: boolean;
|
||||
/** Whether the user can access space settings */
|
||||
canAccessSpaceSettings: boolean;
|
||||
/** Open the space home */
|
||||
openSpaceHome: () => void;
|
||||
/** Display the space invite dialog */
|
||||
inviteInSpace: () => void;
|
||||
/** Open the space preferences */
|
||||
openSpacePreferences: () => void;
|
||||
/** Open the space settings */
|
||||
openSpaceSettings: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for SpaceMenu component
|
||||
*/
|
||||
export interface SpaceMenuProps {
|
||||
/** The view model containing menu data and callbacks */
|
||||
viewModel: SpaceMenuViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The space menu for the room list header.
|
||||
* Displays a dropdown menu with space-specific actions.
|
||||
*/
|
||||
export const SpaceMenu: React.FC<SpaceMenuProps> = ({ viewModel }): JSX.Element => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={viewModel.title}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton className="mx_SpaceMenu_button" aria-label={_t("room_list|open_space_menu")} size="20px">
|
||||
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
Icon={HomeIcon}
|
||||
label={_t("room_list|space_menu|home")}
|
||||
onSelect={viewModel.openSpaceHome}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{viewModel.canInviteInSpace && (
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={viewModel.inviteInSpace}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
Icon={PreferencesIcon}
|
||||
label={_t("common|preferences")}
|
||||
onSelect={viewModel.openSpacePreferences}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{viewModel.canAccessSpaceSettings && (
|
||||
<MenuItem
|
||||
Icon={SettingsIcon}
|
||||
label={_t("room_list|space_menu|space_settings")}
|
||||
onSelect={viewModel.openSpaceSettings}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListHeader } from "./RoomListHeader";
|
||||
export type { RoomListHeaderProps, RoomListHeaderViewModel } from "./RoomListHeader";
|
||||
export { SpaceMenu } from "./SpaceMenu";
|
||||
export type { SpaceMenuProps, SpaceMenuViewModel } from "./SpaceMenu";
|
||||
export { ComposeMenu } from "./ComposeMenu";
|
||||
export type { ComposeMenuProps, ComposeMenuViewModel } from "./ComposeMenu";
|
||||
export { SortOptionsMenu, SortOption } from "./SortOptionsMenu";
|
||||
export type { SortOptionsMenuProps, SortOptionsMenuViewModel } from "./SortOptionsMenu";
|
||||
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The RoomListItem has the following structure:
|
||||
* button--------------------------------------------------|
|
||||
* | <-12px-> container------------------------------------|
|
||||
* | | room avatar <-8px-> content----------------|
|
||||
* | | | room_name <- 20px ->|
|
||||
* | | | --------------------| <-- border
|
||||
* |-------------------------------------------------------|
|
||||
*/
|
||||
.roomListItem {
|
||||
/* Remove button default style */
|
||||
background: unset;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: unset;
|
||||
|
||||
cursor: pointer;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
|
||||
padding-left: var(--cpd-space-3x);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
/* The border is only under the room name and the future hover menu */
|
||||
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding-right: var(--cpd-space-5x);
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roomName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hover {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
}
|
||||
|
||||
.menuOpen .content {
|
||||
/**
|
||||
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
|
||||
* the icon size of the menu is 18px instead of 20px with a different internal padding
|
||||
* We need to use 18px to align the icon with the others icons
|
||||
* 18px is not available in compound spacing
|
||||
*/
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--cpd-color-bg-action-secondary-pressed);
|
||||
}
|
||||
|
||||
.bold .roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
|
||||
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
|
||||
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
|
||||
import type { RoomNotifState } from "../../notifications/RoomNotifs";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
// Mock avatar component
|
||||
const mockAvatar = (
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#0dbd8b",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
TR
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mock notification view model with notifications
|
||||
const mockNotificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: true,
|
||||
count: 3,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
// Mock notification view model without notifications
|
||||
const mockEmptyNotificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
// Mock menu view model
|
||||
const mockMenuViewModel: RoomListItemMenuViewModel = {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
canMarkAsRead: true,
|
||||
canMarkAsUnread: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationAllMessageLoud: false,
|
||||
isNotificationMentionOnly: false,
|
||||
isNotificationMute: false,
|
||||
markAsRead: () => console.log("Mark as read"),
|
||||
markAsUnread: () => console.log("Mark as unread"),
|
||||
toggleFavorite: () => console.log("Toggle favorite"),
|
||||
toggleLowPriority: () => console.log("Toggle low priority"),
|
||||
invite: () => console.log("Invite"),
|
||||
copyRoomLink: () => console.log("Copy room link"),
|
||||
leaveRoom: () => console.log("Leave room"),
|
||||
setRoomNotifState: (state: RoomNotifState) => console.log("Set notification state:", state),
|
||||
};
|
||||
|
||||
const baseViewModel: RoomListItemViewModel = {
|
||||
id: "!test:example.org",
|
||||
name: "Test Room",
|
||||
openRoom: () => console.log("Opening room"),
|
||||
a11yLabel: "Test Room, no unread messages",
|
||||
isBold: false,
|
||||
messagePreview: undefined,
|
||||
notificationViewModel: mockEmptyNotificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListItem",
|
||||
component: RoomListItem,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px", border: "1px solid #ccc" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
viewModel: baseViewModel,
|
||||
isSelected: false,
|
||||
isFocused: false,
|
||||
onFocus: () => {},
|
||||
roomIndex: 0,
|
||||
roomCount: 10,
|
||||
avatar: mockAvatar,
|
||||
},
|
||||
} satisfies Meta<typeof RoomListItem>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithMessagePreview: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
messagePreview: "Alice: Hey, are you coming to the meeting?",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithUnread: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
name: "Team Chat",
|
||||
isBold: true,
|
||||
a11yLabel: "Team Chat, 3 unread messages",
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
isSelected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Focused: Story = {
|
||||
args: {
|
||||
isFocused: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongRoomName: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
name: "This is a very long room name that should be truncated with ellipsis when it exceeds the available width",
|
||||
messagePreview: "And this is also a very long message preview that should also be truncated",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BoldWithPreview: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
name: "Design Team",
|
||||
isBold: true,
|
||||
messagePreview: "Bob shared a new design file",
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: (): React.ReactElement => (
|
||||
<div style={{ width: "320px" }}>
|
||||
<RoomListItem
|
||||
viewModel={baseViewModel}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={() => {}}
|
||||
roomIndex={0}
|
||||
roomCount={5}
|
||||
avatar={mockAvatar}
|
||||
/>
|
||||
<RoomListItem
|
||||
viewModel={{ ...baseViewModel, isBold: true, notificationViewModel: mockNotificationViewModel }}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={() => {}}
|
||||
roomIndex={1}
|
||||
roomCount={5}
|
||||
avatar={mockAvatar}
|
||||
/>
|
||||
<RoomListItem
|
||||
viewModel={baseViewModel}
|
||||
isSelected={true}
|
||||
isFocused={false}
|
||||
onFocus={() => {}}
|
||||
roomIndex={2}
|
||||
roomCount={5}
|
||||
avatar={mockAvatar}
|
||||
/>
|
||||
<RoomListItem
|
||||
viewModel={{ ...baseViewModel, messagePreview: "Latest message" }}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={() => {}}
|
||||
roomIndex={3}
|
||||
roomCount={5}
|
||||
avatar={mockAvatar}
|
||||
/>
|
||||
<RoomListItem
|
||||
viewModel={baseViewModel}
|
||||
isSelected={false}
|
||||
isFocused={true}
|
||||
onFocus={() => {}}
|
||||
roomIndex={4}
|
||||
roomCount={5}
|
||||
avatar={mockAvatar}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
|
||||
import { RoomListItem, type RoomListItemViewModel } from "./RoomListItem";
|
||||
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
|
||||
import type { RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
|
||||
|
||||
describe("RoomListItem", () => {
|
||||
const mockNotificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: false,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: false,
|
||||
isNotification: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const mockMenuViewModel: RoomListItemMenuViewModel = {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
canMarkAsRead: true,
|
||||
canMarkAsUnread: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationAllMessageLoud: false,
|
||||
isNotificationMentionOnly: false,
|
||||
isNotificationMute: false,
|
||||
markAsRead: jest.fn(),
|
||||
markAsUnread: jest.fn(),
|
||||
toggleFavorite: jest.fn(),
|
||||
toggleLowPriority: jest.fn(),
|
||||
invite: jest.fn(),
|
||||
copyRoomLink: jest.fn(),
|
||||
leaveRoom: jest.fn(),
|
||||
setRoomNotifState: jest.fn(),
|
||||
};
|
||||
|
||||
const mockViewModel: RoomListItemViewModel = {
|
||||
id: "!test:example.org",
|
||||
name: "Test Room",
|
||||
openRoom: jest.fn(),
|
||||
a11yLabel: "Test Room, no unread messages",
|
||||
isBold: false,
|
||||
messagePreview: undefined,
|
||||
notificationViewModel: mockNotificationViewModel,
|
||||
menuViewModel: mockMenuViewModel,
|
||||
};
|
||||
|
||||
const mockAvatar = <div data-testid="mock-avatar">Avatar</div>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders room name and avatar", () => {
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={mockViewModel}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Room")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with message preview", () => {
|
||||
const vmWithPreview = { ...mockViewModel, messagePreview: "Latest message preview" };
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={vmWithPreview}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Latest message preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies selected styles when selected", () => {
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={mockViewModel}
|
||||
isSelected={true}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("option");
|
||||
expect(button).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("applies bold styles when room has unread", () => {
|
||||
const vmWithUnread = { ...mockViewModel, isBold: true };
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={vmWithUnread}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("option");
|
||||
// Check that the bold class is applied
|
||||
expect(button.className).toContain("bold");
|
||||
});
|
||||
|
||||
it("calls openRoom when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={mockViewModel}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("option"));
|
||||
expect(mockViewModel.openRoom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onFocus when focused", async () => {
|
||||
const onFocus = jest.fn();
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={mockViewModel}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={onFocus}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("option");
|
||||
button.focus();
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders notification decoration when hasAnyNotificationOrActivity is true", () => {
|
||||
const notificationVM: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: true,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: true,
|
||||
isNotification: false,
|
||||
count: 0,
|
||||
muted: false,
|
||||
};
|
||||
const vmWithNotification = { ...mockViewModel, notificationViewModel: notificationVM };
|
||||
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={vmWithNotification}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={0}
|
||||
roomCount={10}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets correct ARIA attributes", () => {
|
||||
render(
|
||||
<RoomListItem
|
||||
viewModel={mockViewModel}
|
||||
isSelected={false}
|
||||
isFocused={false}
|
||||
onFocus={jest.fn()}
|
||||
roomIndex={5}
|
||||
roomCount={20}
|
||||
avatar={mockAvatar}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("option");
|
||||
expect(button).toHaveAttribute("aria-posinset", "6"); // index + 1
|
||||
expect(button).toHaveAttribute("aria-setsize", "20");
|
||||
expect(button).toHaveAttribute("aria-label", mockViewModel.a11yLabel);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import {
|
||||
NotificationDecoration,
|
||||
type NotificationDecorationViewModel,
|
||||
} from "../../notifications/NotificationDecoration";
|
||||
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
|
||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||
import styles from "./RoomListItem.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListItem
|
||||
* Element-web will provide implementations that connect to Matrix SDK
|
||||
*/
|
||||
export interface RoomListItemViewModel {
|
||||
/** Unique identifier for the room (used for list keying) */
|
||||
id: string;
|
||||
/** The name of the room */
|
||||
name: string;
|
||||
/** Callback to open the room */
|
||||
openRoom: () => void;
|
||||
/** Accessibility label for the room list item */
|
||||
a11yLabel: string;
|
||||
/** Whether the room name should be bolded (has unread/activity) */
|
||||
isBold: boolean;
|
||||
/** Optional message preview text */
|
||||
messagePreview?: string;
|
||||
/** Notification decoration view model */
|
||||
notificationViewModel: NotificationDecorationViewModel;
|
||||
/** Menu view model (for hover and context menus) */
|
||||
menuViewModel: RoomListItemMenuViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListItem component
|
||||
*/
|
||||
export interface RoomListItemProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
|
||||
/** The view model containing room data and actions */
|
||||
viewModel: RoomListItemViewModel;
|
||||
/** Whether the room is currently selected */
|
||||
isSelected: boolean;
|
||||
/** Whether the room is currently focused */
|
||||
isFocused: boolean;
|
||||
/** Callback when the item receives focus */
|
||||
onFocus: (e: React.FocusEvent) => void;
|
||||
/** The index of the room in the list (for accessibility) */
|
||||
roomIndex: number;
|
||||
/** The total number of rooms in the list (for accessibility) */
|
||||
roomCount: number;
|
||||
/** Custom avatar component to render */
|
||||
avatar: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A presentational room list item component.
|
||||
* Displays room name, avatar, message preview, and notifications.
|
||||
* Delegates all business logic to the viewModel and render functions.
|
||||
*/
|
||||
export const RoomListItem = memo(function RoomListItem({
|
||||
viewModel,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onFocus,
|
||||
roomIndex,
|
||||
roomCount,
|
||||
avatar,
|
||||
...props
|
||||
}: RoomListItemProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [isHover, setHover] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// The compound menu needs to be rendered when the hover menu is shown
|
||||
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
|
||||
const showHoverDecoration = isMenuOpen || isFocused || isHover;
|
||||
const showHoverMenu = showHoverDecoration;
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
// To avoid icon blinking when closing the menu, we delay the state update
|
||||
// Also, let the focus move to the menu trigger before closing the menu
|
||||
setTimeout(() => setIsMenuOpen(false), 10);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const content = (
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
className={classNames(styles.roomListItem, {
|
||||
[styles.hover]: showHoverDecoration,
|
||||
[styles.menuOpen]: showHoverMenu,
|
||||
[styles.selected]: isSelected,
|
||||
[styles.bold]: viewModel.isBold,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
type="button"
|
||||
role="option"
|
||||
aria-posinset={roomIndex + 1}
|
||||
aria-setsize={roomCount}
|
||||
aria-selected={isSelected}
|
||||
aria-label={viewModel.a11yLabel}
|
||||
onClick={() => viewModel.openRoom()}
|
||||
onFocus={onFocus}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onBlur={() => setHover(false)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
{...props}
|
||||
>
|
||||
{avatar}
|
||||
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className={styles.text}>
|
||||
<div className={styles.roomName} title={viewModel.name}>
|
||||
{viewModel.name}
|
||||
</div>
|
||||
{viewModel.messagePreview && (
|
||||
<div className={styles.messagePreview} title={viewModel.messagePreview}>
|
||||
{viewModel.messagePreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showHoverMenu ? (
|
||||
<RoomListItemHoverMenu
|
||||
viewModel={viewModel.menuViewModel}
|
||||
onMenuOpenChange={(isOpen: boolean) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div aria-hidden={true}>
|
||||
<NotificationDecoration viewModel={viewModel.notificationViewModel} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoomListItemContextMenu viewModel={viewModel.menuViewModel} onMenuOpenChange={setIsMenuOpen}>
|
||||
{content}
|
||||
</RoomListItemContextMenu>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type PropsWithChildren } from "react";
|
||||
import { ContextMenu } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { MoreOptionContent } from "./RoomListItemHoverMenu";
|
||||
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
|
||||
|
||||
/**
|
||||
* Props for RoomListItemContextMenu component
|
||||
*/
|
||||
export interface RoomListItemContextMenuProps {
|
||||
/** The view model containing menu data and callbacks */
|
||||
viewModel: RoomListItemMenuViewModel;
|
||||
/** Callback when menu open state changes */
|
||||
onMenuOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context menu for room list items.
|
||||
* Wraps the trigger element with a right-click context menu displaying room options.
|
||||
*/
|
||||
export const RoomListItemContextMenu: React.FC<PropsWithChildren<RoomListItemContextMenuProps>> = ({
|
||||
viewModel,
|
||||
onMenuOpenChange,
|
||||
children,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<ContextMenu
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
hasAccessibleAlternative={true}
|
||||
trigger={children}
|
||||
onOpenChange={onMenuOpenChange}
|
||||
>
|
||||
<MoreOptionContent viewModel={viewModel} />
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, type JSX, type ComponentProps } from "react";
|
||||
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
|
||||
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
|
||||
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
|
||||
import ArrowDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/arrow-down";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
|
||||
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
|
||||
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
|
||||
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { RoomNotifState } from "../../notifications/RoomNotifs";
|
||||
import { type RoomListItemMenuViewModel } from "./RoomListItemMenuViewModel";
|
||||
|
||||
/**
|
||||
* Props for RoomListItemHoverMenu component
|
||||
*/
|
||||
export interface RoomListItemHoverMenuProps {
|
||||
/** The view model containing menu data and callbacks */
|
||||
viewModel: RoomListItemMenuViewModel;
|
||||
/** Callback when menu open state changes */
|
||||
onMenuOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hover menu for room list items.
|
||||
* Displays more options and notification settings menus.
|
||||
*/
|
||||
export const RoomListItemHoverMenu: React.FC<RoomListItemHoverMenuProps> = ({
|
||||
viewModel,
|
||||
onMenuOpenChange,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Flex className="mx_RoomListItemHoverMenu" align="center" gap="var(--cpd-space-1x)">
|
||||
{viewModel.showMoreOptionsMenu && (
|
||||
<MoreOptionsMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
|
||||
)}
|
||||
{viewModel.showNotificationMenu && (
|
||||
<NotificationMenu viewModel={viewModel} onMenuOpenChange={onMenuOpenChange} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface MoreOptionsMenuProps {
|
||||
viewModel: RoomListItemMenuViewModel;
|
||||
onMenuOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function MoreOptionsMenu({ viewModel, onMenuOpenChange }: MoreOptionsMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
onMenuOpenChange(isOpen);
|
||||
},
|
||||
[onMenuOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={_t("room_list|room|more_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={<MoreOptionsButton size="24px" />}
|
||||
>
|
||||
<MoreOptionContent viewModel={viewModel} />
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreOptionContentProps {
|
||||
viewModel: RoomListItemMenuViewModel;
|
||||
}
|
||||
|
||||
export function MoreOptionContent({ viewModel }: MoreOptionContentProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onKeyDown={(e) => e.stopPropagation()}>
|
||||
{viewModel.canMarkAsRead && (
|
||||
<MenuItem
|
||||
Icon={MarkAsReadIcon}
|
||||
label={_t("room_list|more_options|mark_read")}
|
||||
onSelect={viewModel.markAsRead}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{viewModel.canMarkAsUnread && (
|
||||
<MenuItem
|
||||
Icon={MarkAsUnreadIcon}
|
||||
label={_t("room_list|more_options|mark_unread")}
|
||||
onSelect={viewModel.markAsUnread}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<ToggleMenuItem
|
||||
checked={viewModel.isFavourite}
|
||||
Icon={FavouriteIcon}
|
||||
label={_t("room_list|more_options|favourited")}
|
||||
onSelect={viewModel.toggleFavorite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
<ToggleMenuItem
|
||||
checked={viewModel.isLowPriority}
|
||||
Icon={ArrowDownIcon}
|
||||
label={_t("room_list|more_options|low_priority")}
|
||||
onSelect={viewModel.toggleLowPriority}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
{viewModel.canInvite && (
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={viewModel.invite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{viewModel.canCopyRoomLink && (
|
||||
<MenuItem
|
||||
Icon={LinkIcon}
|
||||
label={_t("room_list|more_options|copy_link")}
|
||||
onSelect={viewModel.copyRoomLink}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<Separator />
|
||||
<MenuItem
|
||||
kind="critical"
|
||||
Icon={LeaveIcon}
|
||||
label={_t("room_list|more_options|leave_room")}
|
||||
onSelect={viewModel.leaveRoom}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps<typeof IconButton>): JSX.Element {
|
||||
return (
|
||||
<Tooltip label={_t("room_list|room|more_options")}>
|
||||
<IconButton aria-label={_t("room_list|room|more_options")} {...props}>
|
||||
<OverflowIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface NotificationMenuProps {
|
||||
viewModel: RoomListItemMenuViewModel;
|
||||
onMenuOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function NotificationMenu({ viewModel, onMenuOpenChange }: NotificationMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
onMenuOpenChange(isOpen);
|
||||
},
|
||||
[onMenuOpenChange],
|
||||
);
|
||||
|
||||
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onKeyDown={(e) => e.stopPropagation()}>
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={_t("room_list|notification_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={<NotificationButton isRoomMuted={viewModel.isNotificationMute} size="24px" />}
|
||||
>
|
||||
<MenuItem
|
||||
aria-selected={viewModel.isNotificationAllMessage}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|default_settings")}
|
||||
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessages)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{viewModel.isNotificationAllMessage && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={viewModel.isNotificationAllMessageLoud}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|all_messages")}
|
||||
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{viewModel.isNotificationAllMessageLoud && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={viewModel.isNotificationMentionOnly}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mentions_keywords")}
|
||||
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.MentionsOnly)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{viewModel.isNotificationMentionOnly && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={viewModel.isNotificationMute}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mute_room")}
|
||||
onSelect={() => viewModel.setRoomNotifState(RoomNotifState.Mute)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{viewModel.isNotificationMute && checkComponent}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
|
||||
isRoomMuted: boolean;
|
||||
}
|
||||
|
||||
const NotificationButton = function NotificationButton({
|
||||
isRoomMuted,
|
||||
...props
|
||||
}: NotificationButtonProps): JSX.Element {
|
||||
return (
|
||||
<Tooltip label={_t("room_list|notification_options")}>
|
||||
<IconButton aria-label={_t("room_list|notification_options")} {...props}>
|
||||
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RoomNotifState } from "../../notifications/RoomNotifs";
|
||||
|
||||
/**
|
||||
* ViewModel interface for room list item menus (hover menu and context menu).
|
||||
* Contains all the data and callbacks needed to render the menu options.
|
||||
*/
|
||||
export interface RoomListItemMenuViewModel {
|
||||
/** Whether the more options menu should be shown */
|
||||
showMoreOptionsMenu: boolean;
|
||||
/** Whether the notification menu should be shown */
|
||||
showNotificationMenu: boolean;
|
||||
/** Whether the room is a favourite room */
|
||||
isFavourite: boolean;
|
||||
/** Whether the room is a low priority room */
|
||||
isLowPriority: boolean;
|
||||
/** Can invite other users in the room */
|
||||
canInvite: boolean;
|
||||
/** Can copy the room link */
|
||||
canCopyRoomLink: boolean;
|
||||
/** Can mark the room as read */
|
||||
canMarkAsRead: boolean;
|
||||
/** Can mark the room as unread */
|
||||
canMarkAsUnread: boolean;
|
||||
/** Whether the notification is set to all messages */
|
||||
isNotificationAllMessage: boolean;
|
||||
/** Whether the notification is set to all messages loud */
|
||||
isNotificationAllMessageLoud: boolean;
|
||||
/** Whether the notification is set to mentions and keywords only */
|
||||
isNotificationMentionOnly: boolean;
|
||||
/** Whether the notification is muted */
|
||||
isNotificationMute: boolean;
|
||||
/** Mark the room as read */
|
||||
markAsRead: () => void;
|
||||
/** Mark the room as unread */
|
||||
markAsUnread: () => void;
|
||||
/** Toggle the room as favourite */
|
||||
toggleFavorite: () => void;
|
||||
/** Toggle the room as low priority */
|
||||
toggleLowPriority: () => void;
|
||||
/** Invite other users in the room */
|
||||
invite: () => void;
|
||||
/** Copy the room link to clipboard */
|
||||
copyRoomLink: () => void;
|
||||
/** Leave the room */
|
||||
leaveRoom: () => void;
|
||||
/** Set the room notification state */
|
||||
setRoomNotifState: (state: RoomNotifState) => void;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListItem } from "./RoomListItem";
|
||||
export type { RoomListItemProps, RoomListItemViewModel } from "./RoomListItem";
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.roomListPanel {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
|
||||
import { type RoomsResult } from "../RoomList";
|
||||
import { type RoomListItemViewModel } from "../RoomListItem";
|
||||
import type { NotificationDecorationViewModel } from "../../notifications/NotificationDecoration";
|
||||
import { SortOption } from "../RoomListHeader/SortOptionsMenu";
|
||||
import { type FilterViewModel } from "../RoomListPrimaryFilters/useVisibleFilters";
|
||||
|
||||
// Mock avatar component
|
||||
const mockAvatar = (roomViewModel: RoomListItemViewModel): React.ReactElement => (
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#0dbd8b",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{roomViewModel.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Generate mock rooms
|
||||
const generateMockRooms = (count: number): RoomListItemViewModel[] => {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const unreadCount = Math.random() > 0.7 ? Math.floor(Math.random() * 10) : 0;
|
||||
const hasNotification = Math.random() > 0.8;
|
||||
|
||||
const notificationViewModel: NotificationDecorationViewModel = {
|
||||
hasAnyNotificationOrActivity: unreadCount > 0,
|
||||
isUnsentMessage: false,
|
||||
invited: false,
|
||||
isMention: false,
|
||||
isActivityNotification: unreadCount > 0 && !hasNotification,
|
||||
isNotification: hasNotification,
|
||||
count: unreadCount,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
return {
|
||||
id: `!room${i}:server`,
|
||||
name: `Room ${i + 1}`,
|
||||
openRoom: () => console.log(`Opening room: Room ${i + 1}`),
|
||||
a11yLabel: unreadCount ? `Room ${i + 1}, ${unreadCount} unread messages` : `Room ${i + 1}`,
|
||||
isBold: unreadCount > 0,
|
||||
messagePreview: undefined,
|
||||
notificationViewModel,
|
||||
menuViewModel: {
|
||||
showMoreOptionsMenu: true,
|
||||
showNotificationMenu: true,
|
||||
canMarkAsRead: unreadCount > 0,
|
||||
canMarkAsUnread: unreadCount === 0,
|
||||
isFavourite: false,
|
||||
isLowPriority: false,
|
||||
canInvite: true,
|
||||
canCopyRoomLink: true,
|
||||
isNotificationAllMessage: true,
|
||||
isNotificationAllMessageLoud: false,
|
||||
isNotificationMentionOnly: false,
|
||||
isNotificationMute: false,
|
||||
markAsRead: () => console.log(`Mark read: Room ${i + 1}`),
|
||||
markAsUnread: () => console.log(`Mark unread: Room ${i + 1}`),
|
||||
toggleFavorite: () => console.log(`Toggle favorite: Room ${i + 1}`),
|
||||
toggleLowPriority: () => console.log(`Toggle low priority: Room ${i + 1}`),
|
||||
invite: () => console.log(`Invite: Room ${i + 1}`),
|
||||
copyRoomLink: () => console.log(`Copy link: Room ${i + 1}`),
|
||||
leaveRoom: () => console.log(`Leave: Room ${i + 1}`),
|
||||
setRoomNotifState: (state) => console.log(`Set notif state: ${state}`),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mockRoomsResult: RoomsResult = {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: generateMockRooms(20),
|
||||
};
|
||||
|
||||
// Create mock filters
|
||||
const createFilters = (): FilterViewModel[] => {
|
||||
const filters = ["All", "People", "Rooms", "Favourites", "Unread"];
|
||||
return filters.map((name, index) => ({
|
||||
name,
|
||||
active: index === 0,
|
||||
toggle: () => console.log(`Filter: ${name}`),
|
||||
}));
|
||||
};
|
||||
|
||||
const meta: Meta<typeof RoomListPanel> = {
|
||||
title: "Room List/RoomListPanel",
|
||||
component: RoomListPanel,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoomListPanel>;
|
||||
|
||||
const baseViewModel: RoomListPanelViewModel = {
|
||||
ariaLabel: "Room list navigation",
|
||||
searchViewModel: {
|
||||
onSearchClick: () => console.log("Open search"),
|
||||
showDialPad: false,
|
||||
showExplore: true,
|
||||
onExploreClick: () => console.log("Explore rooms"),
|
||||
},
|
||||
headerViewModel: {
|
||||
title: "Home",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: () => console.log("Compose"),
|
||||
sortOptionsMenuViewModel: {
|
||||
activeSortOption: SortOption.Activity,
|
||||
sort: (option) => console.log(`Sort: ${option}`),
|
||||
},
|
||||
},
|
||||
viewViewModel: {
|
||||
isLoadingRooms: false,
|
||||
isRoomListEmpty: false,
|
||||
filtersViewModel: {
|
||||
filters: createFilters(),
|
||||
},
|
||||
roomListViewModel: {
|
||||
roomsResult: mockRoomsResult,
|
||||
activeRoomIndex: 0,
|
||||
},
|
||||
emptyStateTitle: "No rooms",
|
||||
emptyStateDescription: "Join a room to get started",
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
viewModel: baseViewModel,
|
||||
renderAvatar: mockAvatar,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px", width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
searchViewModel: undefined,
|
||||
},
|
||||
renderAvatar: mockAvatar,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px", width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
viewViewModel: {
|
||||
...baseViewModel.viewViewModel,
|
||||
isLoadingRooms: true,
|
||||
},
|
||||
},
|
||||
renderAvatar: mockAvatar,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px", width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
...baseViewModel,
|
||||
viewViewModel: {
|
||||
...baseViewModel.viewViewModel,
|
||||
isRoomListEmpty: true,
|
||||
emptyStateTitle: "No rooms to display",
|
||||
emptyStateDescription: "Join a room or start a conversation to get started",
|
||||
},
|
||||
},
|
||||
renderAvatar: mockAvatar,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "600px", width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
|
||||
import { RoomListPanel, type RoomListPanelViewModel } from "./RoomListPanel";
|
||||
import { SortOption } from "../RoomListHeader";
|
||||
import type { RoomListItemViewModel } from "../RoomListItem";
|
||||
|
||||
describe("RoomListPanel", () => {
|
||||
const mockRenderAvatar = jest.fn((roomViewModel: RoomListItemViewModel) => (
|
||||
<div data-testid={`avatar-${roomViewModel.id}`}>{roomViewModel.name[0]}</div>
|
||||
));
|
||||
|
||||
const mockViewModel: RoomListPanelViewModel = {
|
||||
ariaLabel: "Room List",
|
||||
searchViewModel: {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: false,
|
||||
showExplore: false,
|
||||
},
|
||||
headerViewModel: {
|
||||
title: "Test Header",
|
||||
isSpace: false,
|
||||
displayComposeMenu: false,
|
||||
onComposeClick: jest.fn(),
|
||||
sortOptionsMenuViewModel: {
|
||||
activeSortOption: SortOption.Activity,
|
||||
sort: jest.fn(),
|
||||
},
|
||||
},
|
||||
viewViewModel: {
|
||||
isLoadingRooms: false,
|
||||
isRoomListEmpty: false,
|
||||
emptyStateTitle: "No rooms",
|
||||
filtersViewModel: {
|
||||
filters: [],
|
||||
},
|
||||
roomListViewModel: {
|
||||
roomsResult: {
|
||||
spaceId: "!space:server",
|
||||
filterKeys: undefined,
|
||||
rooms: [],
|
||||
},
|
||||
activeRoomIndex: undefined,
|
||||
onKeyDown: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("renders with search, header, and content", () => {
|
||||
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
expect(screen.getByText("Test Header")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without search", () => {
|
||||
const vmWithoutSearch = {
|
||||
...mockViewModel,
|
||||
searchViewModel: undefined,
|
||||
};
|
||||
|
||||
render(<RoomListPanel viewModel={vmWithoutSearch} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
expect(screen.getByText("Test Header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders loading state", () => {
|
||||
const vmLoading: RoomListPanelViewModel = {
|
||||
...mockViewModel,
|
||||
viewViewModel: {
|
||||
...mockViewModel.viewViewModel,
|
||||
isLoadingRooms: true,
|
||||
isRoomListEmpty: false,
|
||||
},
|
||||
};
|
||||
|
||||
render(<RoomListPanel viewModel={vmLoading} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
// RoomListPanel should render (loading state is handled by RoomListView)
|
||||
expect(screen.getByRole("navigation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state", () => {
|
||||
const vmEmpty: RoomListPanelViewModel = {
|
||||
...mockViewModel,
|
||||
viewViewModel: {
|
||||
...mockViewModel.viewViewModel,
|
||||
isLoadingRooms: false,
|
||||
isRoomListEmpty: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(<RoomListPanel viewModel={vmEmpty} renderAvatar={mockRenderAvatar} />);
|
||||
|
||||
// RoomListPanel should render (empty state is handled by RoomListView)
|
||||
expect(screen.getByRole("navigation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes additional HTML attributes", () => {
|
||||
render(<RoomListPanel viewModel={mockViewModel} renderAvatar={mockRenderAvatar} data-testid="custom-panel" />);
|
||||
|
||||
expect(screen.getByTestId("custom-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ReactNode } from "react";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { RoomListSearch, type RoomListSearchViewModel } from "../RoomListSearch";
|
||||
import { RoomListHeader, type RoomListHeaderViewModel } from "../RoomListHeader";
|
||||
import { RoomListView, type RoomListViewViewModel } from "../RoomListView";
|
||||
import { type RoomListItemViewModel } from "../RoomListItem";
|
||||
import styles from "./RoomListPanel.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListPanel
|
||||
*/
|
||||
export interface RoomListPanelViewModel {
|
||||
/** Accessibility label for the navigation landmark */
|
||||
ariaLabel: string;
|
||||
/** Optional search view model */
|
||||
searchViewModel?: RoomListSearchViewModel;
|
||||
/** Header view model */
|
||||
headerViewModel: RoomListHeaderViewModel;
|
||||
/** View model for the main content area */
|
||||
viewViewModel: RoomListViewViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListPanel component
|
||||
*/
|
||||
export interface RoomListPanelProps extends React.HTMLAttributes<HTMLElement> {
|
||||
/** The view model containing all data and callbacks */
|
||||
viewModel: RoomListPanelViewModel;
|
||||
/** Render function for room avatar */
|
||||
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete room list panel component.
|
||||
* Composes search, header, and content areas with a ViewModel pattern.
|
||||
*/
|
||||
export const RoomListPanel: React.FC<RoomListPanelProps> = ({ viewModel, renderAvatar, ...props }): JSX.Element => {
|
||||
return (
|
||||
<Flex
|
||||
as="nav"
|
||||
className={styles.roomListPanel}
|
||||
direction="column"
|
||||
align="stretch"
|
||||
aria-label={viewModel.ariaLabel}
|
||||
{...props}
|
||||
>
|
||||
{viewModel.searchViewModel && <RoomListSearch viewModel={viewModel.searchViewModel} />}
|
||||
<RoomListHeader viewModel={viewModel.headerViewModel} />
|
||||
<RoomListView viewModel={viewModel.viewViewModel} renderAvatar={renderAvatar} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListPanel } from "./RoomListPanel";
|
||||
export type { RoomListPanelProps } from "./RoomListPanel";
|
||||
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.roomListPrimaryFilters {
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.list {
|
||||
/**
|
||||
* The InteractionObserver needs the height to be set to work properly.
|
||||
*/
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Styles for element-web wrapping class */
|
||||
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_wrapping) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* IconButton styles for chevron */
|
||||
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_IconButton) svg {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.roomListPrimaryFilters :global(.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"]) svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
import type { FilterViewModel } from "./useVisibleFilters";
|
||||
|
||||
const meta: Meta<typeof RoomListPrimaryFilters> = {
|
||||
title: "Room List/RoomListPrimaryFilters",
|
||||
component: RoomListPrimaryFilters,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoomListPrimaryFilters>;
|
||||
|
||||
// Mock filter data - simple presentation data only
|
||||
const createFilters = (selectedIndex: number = 0): FilterViewModel[] => {
|
||||
const filterNames = ["All", "People", "Rooms", "Favourites", "Unread"];
|
||||
|
||||
return filterNames.map((name, index) => ({
|
||||
name,
|
||||
active: index === selectedIndex,
|
||||
toggle: () => console.log(`Filter toggled: ${name}`),
|
||||
}));
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
filters: createFilters(0),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PeopleSelected: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
filters: createFilters(1),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FewFilters: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
filters: [
|
||||
{
|
||||
name: "All",
|
||||
active: true,
|
||||
toggle: () => console.log("All toggled"),
|
||||
},
|
||||
{
|
||||
name: "Unread",
|
||||
active: false,
|
||||
toggle: () => console.log("Unread toggled"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useId, useState } from "react";
|
||||
import { ChatFilter, IconButton } from "@vector-im/compound-web";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import { useCollapseFilters } from "./useCollapseFilters";
|
||||
import { useVisibleFilters, type FilterViewModel } from "./useVisibleFilters";
|
||||
import styles from "./RoomListPrimaryFilters.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListPrimaryFilters - contains only presentation data
|
||||
*/
|
||||
export interface RoomListPrimaryFiltersViewModel {
|
||||
/** Array of filter data */
|
||||
filters: FilterViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListPrimaryFilters component
|
||||
*/
|
||||
export interface RoomListPrimaryFiltersProps {
|
||||
/** The view model containing filter data */
|
||||
viewModel: RoomListPrimaryFiltersViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary filters component for the room list.
|
||||
* Displays a collapsible list of filters with expand/collapse functionality.
|
||||
*/
|
||||
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ viewModel }): JSX.Element => {
|
||||
const id = useId();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
|
||||
const filters = useVisibleFilters(viewModel.filters, wrappingIndex);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={styles.roomListPrimaryFilters}
|
||||
data-testid="primary-filters"
|
||||
gap="var(--cpd-space-3x)"
|
||||
direction="row-reverse"
|
||||
justify="space-between"
|
||||
>
|
||||
{displayChevron && (
|
||||
<IconButton
|
||||
kind="secondary"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={id}
|
||||
className="mx_RoomListPrimaryFilters_IconButton"
|
||||
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
|
||||
size="28px"
|
||||
onClick={() => setIsExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Flex
|
||||
id={id}
|
||||
as="div"
|
||||
role="listbox"
|
||||
aria-label={_t("room_list|primary_filters")}
|
||||
align="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
wrap="wrap"
|
||||
className={styles.list}
|
||||
ref={ref}
|
||||
>
|
||||
{filters.map((filter, i) => (
|
||||
<ChatFilter key={i} role="option" selected={filter.active} onClick={() => filter.toggle()}>
|
||||
{filter.name}
|
||||
</ChatFilter>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ReactNode, type RefObject } from "react";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import styles from "./RoomListPrimaryFilters.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListPrimaryFilters
|
||||
*/
|
||||
export interface RoomListPrimaryFiltersViewModel {
|
||||
/** Unique ID for accessibility */
|
||||
id: string;
|
||||
/** Whether to display the chevron button */
|
||||
displayChevron: boolean;
|
||||
/** The chevron button component */
|
||||
chevronButton?: ReactNode;
|
||||
/** The filter elements to display */
|
||||
filterElements: ReactNode[];
|
||||
/** Accessibility label for the filter list */
|
||||
filtersAriaLabel: string;
|
||||
/** Ref to attach to the filter list container */
|
||||
filtersRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListPrimaryFilters component
|
||||
*/
|
||||
export interface RoomListPrimaryFiltersProps {
|
||||
/** The view model containing filter data */
|
||||
viewModel: RoomListPrimaryFiltersViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary filters component for the room list.
|
||||
* Displays a collapsible list of filters.
|
||||
*/
|
||||
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({ viewModel }): JSX.Element => {
|
||||
return (
|
||||
<Flex
|
||||
className={styles.roomListPrimaryFilters}
|
||||
data-testid="primary-filters"
|
||||
gap="var(--cpd-space-3x)"
|
||||
direction="row-reverse"
|
||||
justify="space-between"
|
||||
>
|
||||
{viewModel.displayChevron && viewModel.chevronButton}
|
||||
<Flex
|
||||
id={viewModel.id}
|
||||
as="div"
|
||||
role="listbox"
|
||||
aria-label={viewModel.filtersAriaLabel}
|
||||
align="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
wrap="wrap"
|
||||
className={styles.list}
|
||||
ref={viewModel.filtersRef}
|
||||
>
|
||||
{viewModel.filterElements.map((element, i) => (
|
||||
<React.Fragment key={i}>{element}</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
export type { RoomListPrimaryFiltersProps, RoomListPrimaryFiltersViewModel } from "./RoomListPrimaryFilters";
|
||||
export { useCollapseFilters } from "./useCollapseFilters";
|
||||
export { useVisibleFilters } from "./useVisibleFilters";
|
||||
export type { FilterViewModel } from "./useVisibleFilters";
|
||||
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* A hook to manage the wrapping of filters in the room list.
|
||||
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
|
||||
* @param isExpanded
|
||||
* @returns an object containing:
|
||||
* - `ref`: a ref to put on the filter list element
|
||||
* - `isWrapping`: a boolean indicating if the filters are wrapping
|
||||
* - `wrappingIndex`: the index of the first filter that is wrapping
|
||||
*/
|
||||
export function useCollapseFilters<T extends HTMLElement>(
|
||||
isExpanded: boolean,
|
||||
): {
|
||||
ref: RefObject<T | null>;
|
||||
isWrapping: boolean;
|
||||
wrappingIndex: number;
|
||||
} {
|
||||
const ref = useRef<T>(null);
|
||||
const [isWrapping, setIsWrapping] = useState(false);
|
||||
const [wrappingIndex, setWrappingIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const hideFilters = (list: Element): void => {
|
||||
let isWrapping = false;
|
||||
Array.from(list.children).forEach((node, i): void => {
|
||||
const child = node as HTMLElement;
|
||||
const wrappingClass = "mx_RoomListPrimaryFilters_wrapping";
|
||||
child.setAttribute("aria-hidden", "false");
|
||||
child.classList.remove(wrappingClass);
|
||||
|
||||
// If the filter list is expanded, all filters are visible
|
||||
if (isExpanded) return;
|
||||
|
||||
// If the previous element is on the left element of the current one, it means that the filter is wrapping
|
||||
const previousSibling = child.previousElementSibling as HTMLElement | null;
|
||||
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
|
||||
if (!isWrapping) setWrappingIndex(i);
|
||||
isWrapping = true;
|
||||
}
|
||||
|
||||
// If the filter is wrapping, we hide it
|
||||
child.classList.toggle(wrappingClass, isWrapping);
|
||||
child.setAttribute("aria-hidden", isWrapping.toString());
|
||||
});
|
||||
|
||||
if (!isWrapping) setWrappingIndex(-1);
|
||||
setIsWrapping(isExpanded || isWrapping);
|
||||
};
|
||||
|
||||
hideFilters(ref.current);
|
||||
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
|
||||
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
return { ref, isWrapping, wrappingIndex };
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface FilterViewModel {
|
||||
/** Filter name/label */
|
||||
name: string;
|
||||
/** Whether the filter is currently active */
|
||||
active: boolean;
|
||||
/** Callback when filter is clicked */
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook to sort the filters by active state.
|
||||
* The list is sorted if the current filter index is greater than or equal to the wrapping index.
|
||||
* If the wrapping index is -1, the filters are not sorted.
|
||||
*
|
||||
* @param filters - the list of filters to sort.
|
||||
* @param wrappingIndex - the index of the first filter that is wrapping.
|
||||
*/
|
||||
export function useVisibleFilters(filters: FilterViewModel[], wrappingIndex: number): FilterViewModel[] {
|
||||
// By default, the filters are not sorted
|
||||
const [sortedFilters, setSortedFilters] = useState(filters);
|
||||
|
||||
useEffect(() => {
|
||||
const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex;
|
||||
// If the active filter is not wrapping, we don't need to sort the filters
|
||||
if (!isActiveFilterWrapping || wrappingIndex === -1) {
|
||||
setSortedFilters(filters);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the filters with the current filter at first position
|
||||
setSortedFilters(
|
||||
filters.slice().sort((filterA, filterB) => {
|
||||
// If the filter is active, it should be at the top of the list
|
||||
if (filterA.active && !filterB.active) return -1;
|
||||
if (!filterA.active && filterB.active) return 1;
|
||||
// If both filters are active or not, keep their original order
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
}, [filters, wrappingIndex]);
|
||||
|
||||
return sortedFilters;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.roomListSearch {
|
||||
/* From figma, this should be aligned with the room header */
|
||||
flex: 0 0 64px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
|
||||
padding: 0 var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
/* Styles for the search button when used in element-web */
|
||||
.roomListSearch :global(.mx_RoomListSearch_search) {
|
||||
/* The search button should take all the remaining space */
|
||||
flex: 1;
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roomListSearch :global(.mx_RoomListSearch_search) svg {
|
||||
fill: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.roomListSearch :global(.mx_RoomListSearch_search) span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roomListSearch :global(.mx_RoomListSearch_search) kbd {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Shrink and truncate the search text */
|
||||
.roomListSearch :global(.mx_RoomListSearch_search_text) {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RoomListSearch } from "./RoomListSearch";
|
||||
|
||||
const meta: Meta<typeof RoomListSearch> = {
|
||||
title: "Room List/RoomListSearch",
|
||||
component: RoomListSearch,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoomListSearch>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
onSearchClick: () => console.log("Open search"),
|
||||
showDialPad: false,
|
||||
showExplore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDialPad: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
onSearchClick: () => console.log("Open search"),
|
||||
showDialPad: true,
|
||||
onDialPadClick: () => console.log("Open dial pad"),
|
||||
showExplore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithExplore: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
onSearchClick: () => console.log("Open search"),
|
||||
showDialPad: false,
|
||||
showExplore: true,
|
||||
onExploreClick: () => console.log("Explore rooms"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAllActions: Story = {
|
||||
args: {
|
||||
viewModel: {
|
||||
onSearchClick: () => console.log("Open search"),
|
||||
showDialPad: true,
|
||||
onDialPadClick: () => console.log("Open dial pad"),
|
||||
showExplore: true,
|
||||
onExploreClick: () => console.log("Explore rooms"),
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { RoomListSearch, type RoomListSearchViewModel } from "./RoomListSearch";
|
||||
|
||||
describe("RoomListSearch", () => {
|
||||
it("renders search button with shortcut", () => {
|
||||
const onSearchClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick,
|
||||
showDialPad: false,
|
||||
showExplore: false,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByRole("search")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
|
||||
// Keyboard shortcut should be visible
|
||||
expect(screen.getByText(/K/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSearchClick when search button is clicked", async () => {
|
||||
const onSearchClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick,
|
||||
showDialPad: false,
|
||||
showExplore: false,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /search/i }));
|
||||
expect(onSearchClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders dial pad button when showDialPad is true", () => {
|
||||
const onDialPadClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: true,
|
||||
onDialPadClick,
|
||||
showExplore: false,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onDialPadClick when dial pad button is clicked", async () => {
|
||||
const onDialPadClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: true,
|
||||
onDialPadClick,
|
||||
showExplore: false,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /dial pad/i }));
|
||||
expect(onDialPadClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders explore button when showExplore is true", () => {
|
||||
const onExploreClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: false,
|
||||
showExplore: true,
|
||||
onExploreClick,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onExploreClick when explore button is clicked", async () => {
|
||||
const onExploreClick = jest.fn();
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: false,
|
||||
showExplore: true,
|
||||
onExploreClick,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /explore/i }));
|
||||
expect(onExploreClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders all buttons when showDialPad and showExplore are true", () => {
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: true,
|
||||
onDialPadClick: jest.fn(),
|
||||
showExplore: true,
|
||||
onExploreClick: jest.fn(),
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /dial pad/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /explore/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dial pad or explore buttons when flags are false", () => {
|
||||
const viewModel: RoomListSearchViewModel = {
|
||||
onSearchClick: jest.fn(),
|
||||
showDialPad: false,
|
||||
showExplore: false,
|
||||
};
|
||||
|
||||
render(<RoomListSearch viewModel={viewModel} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /dial pad/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /explore/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
|
||||
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
|
||||
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import { _t } from "../../utils/i18n";
|
||||
import styles from "./RoomListSearch.module.css";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListSearch
|
||||
*/
|
||||
export interface RoomListSearchViewModel {
|
||||
/** Callback fired when search button is clicked */
|
||||
onSearchClick: () => void;
|
||||
/** Whether to show the dial pad button */
|
||||
showDialPad: boolean;
|
||||
/** Callback fired when dial pad button is clicked */
|
||||
onDialPadClick?: () => void;
|
||||
/** Whether to show the explore rooms button */
|
||||
showExplore: boolean;
|
||||
/** Callback fired when explore button is clicked */
|
||||
onExploreClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListSearch component
|
||||
*/
|
||||
export interface RoomListSearchProps {
|
||||
/** The view model containing search data */
|
||||
viewModel: RoomListSearchViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A presentational search bar component for the room list.
|
||||
* Displays a search button and optional action buttons (dial pad, explore) in a horizontal layout.
|
||||
*/
|
||||
export const RoomListSearch: React.FC<RoomListSearchProps> = ({ viewModel }): JSX.Element => {
|
||||
// Determine keyboard shortcut based on platform
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
const searchShortcut = isMac ? "⌘ K" : "Ctrl K";
|
||||
|
||||
return (
|
||||
<Flex className={styles.roomListSearch} role="search" gap="var(--cpd-space-2x)" align="center">
|
||||
<Button
|
||||
className="mx_RoomListSearch_search"
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
Icon={SearchIcon}
|
||||
onClick={viewModel.onSearchClick}
|
||||
>
|
||||
<Flex as="span" justify="space-between">
|
||||
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
|
||||
<kbd>{searchShortcut}</kbd>
|
||||
</Flex>
|
||||
</Button>
|
||||
{viewModel.showDialPad && (
|
||||
<Button
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
Icon={DialPadIcon}
|
||||
iconOnly={true}
|
||||
aria-label={_t("left_panel|open_dial_pad")}
|
||||
onClick={viewModel.onDialPadClick}
|
||||
/>
|
||||
)}
|
||||
{viewModel.showExplore && (
|
||||
<Button
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
Icon={ExploreIcon}
|
||||
iconOnly={true}
|
||||
aria-label={_t("action|explore_rooms")}
|
||||
onClick={viewModel.onExploreClick}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListSearch } from "./RoomListSearch";
|
||||
export type { RoomListSearchProps, RoomListSearchViewModel } from "./RoomListSearch";
|
||||
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ReactNode } from "react";
|
||||
|
||||
import styles from "./RoomListView.module.css";
|
||||
|
||||
/**
|
||||
* Props for RoomListEmptyState component
|
||||
*/
|
||||
export interface RoomListEmptyStateProps {
|
||||
/** The title to display in the empty state */
|
||||
title: string;
|
||||
/** The description text to display */
|
||||
description?: string;
|
||||
/** Optional action element (e.g., a button) to display */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state component for the room list.
|
||||
* Displays a message when no rooms are available.
|
||||
*/
|
||||
export const RoomListEmptyState: React.FC<RoomListEmptyStateProps> = ({ title, description, action }): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<h3>{title}</h3>
|
||||
{description && <p>{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
|
||||
import styles from "./RoomListView.module.css";
|
||||
|
||||
/**
|
||||
* Loading skeleton component for the room list.
|
||||
* Displays a simple loading indicator while rooms are being fetched.
|
||||
*/
|
||||
export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
|
||||
return <div className={styles.skeleton} />;
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Skeleton animation - note: mask-image requires SVG from element-web */
|
||||
.skeleton::before {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
position: absolute;
|
||||
mask-repeat: repeat-y;
|
||||
mask-size: auto 96px;
|
||||
}
|
||||
|
||||
/* Element-web provides the actual mask-image */
|
||||
:global(.mx_RoomListSkeleton)::before {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ReactNode } from "react";
|
||||
|
||||
import { RoomListPrimaryFilters, type RoomListPrimaryFiltersViewModel } from "../RoomListPrimaryFilters";
|
||||
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
||||
import { RoomListEmptyState } from "./RoomListEmptyState";
|
||||
import { RoomList, type RoomListViewModel } from "../RoomList";
|
||||
import { type RoomListItemViewModel } from "../RoomListItem";
|
||||
|
||||
/**
|
||||
* ViewModel interface for RoomListView
|
||||
*/
|
||||
export interface RoomListViewViewModel {
|
||||
/** Whether the rooms are currently loading */
|
||||
isLoadingRooms: boolean;
|
||||
/** Whether the room list is empty */
|
||||
isRoomListEmpty: boolean;
|
||||
/** View model for the primary filters */
|
||||
filtersViewModel: RoomListPrimaryFiltersViewModel;
|
||||
/** View model for the room list */
|
||||
roomListViewModel: RoomListViewModel;
|
||||
/** Title for the empty state */
|
||||
emptyStateTitle: string;
|
||||
/** Optional description for the empty state */
|
||||
emptyStateDescription?: string;
|
||||
/** Optional action element for the empty state */
|
||||
emptyStateAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for RoomListView component
|
||||
*/
|
||||
export interface RoomListViewProps {
|
||||
/** The view model containing list data */
|
||||
viewModel: RoomListViewViewModel;
|
||||
/** Render function for room avatar */
|
||||
renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main room list view component.
|
||||
* Manages the display of filters, loading states, empty states, and the room list.
|
||||
*/
|
||||
export const RoomListView: React.FC<RoomListViewProps> = ({ viewModel, renderAvatar }): JSX.Element => {
|
||||
let listBody: ReactNode;
|
||||
|
||||
if (viewModel.isLoadingRooms) {
|
||||
listBody = <RoomListLoadingSkeleton />;
|
||||
} else if (viewModel.isRoomListEmpty) {
|
||||
listBody = (
|
||||
<RoomListEmptyState
|
||||
title={viewModel.emptyStateTitle}
|
||||
description={viewModel.emptyStateDescription}
|
||||
action={viewModel.emptyStateAction}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
listBody = <RoomList viewModel={viewModel.roomListViewModel} renderAvatar={renderAvatar} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomListPrimaryFilters viewModel={viewModel.filtersViewModel} />
|
||||
{listBody}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListView } from "./RoomListView";
|
||||
export type { RoomListViewProps, RoomListViewViewModel } from "./RoomListView";
|
||||
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
|
||||
export { RoomListEmptyState } from "./RoomListEmptyState";
|
||||
export type { RoomListEmptyStateProps } from "./RoomListEmptyState";
|
||||
@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "@testing-library/jest-dom";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
|
||||
import { setLanguage } from "../../src/utils/i18n";
|
||||
|
||||
335
packages/shared-components/src/utils/ListView/ListView.tsx
Normal file
335
packages/shared-components/src/utils/ListView/ListView.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
|
||||
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
|
||||
|
||||
/**
|
||||
* Keyboard key codes
|
||||
*/
|
||||
export const Key = {
|
||||
ARROW_UP: "ArrowUp",
|
||||
ARROW_DOWN: "ArrowDown",
|
||||
HOME: "Home",
|
||||
END: "End",
|
||||
PAGE_UP: "PageUp",
|
||||
PAGE_DOWN: "PageDown",
|
||||
ENTER: "Enter",
|
||||
SPACE: "Space",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if a keyboard event includes modifier keys
|
||||
*/
|
||||
export function isModifiedKeyEvent(event: React.KeyboardEvent): boolean {
|
||||
return event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context object passed to each list item containing the currently focused key
|
||||
* and any additional context data from the parent component.
|
||||
*/
|
||||
export type ListContext<Context> = {
|
||||
/** The key of item that should have tabIndex == 0 */
|
||||
tabIndexKey?: string;
|
||||
/** Whether an item in the list is currently focused */
|
||||
focused: boolean;
|
||||
/** Additional context data passed from the parent component */
|
||||
context: Context;
|
||||
};
|
||||
|
||||
export interface IListViewProps<Item, Context>
|
||||
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "data" | "itemContent" | "context"> {
|
||||
/**
|
||||
* The array of items to display in the virtualized list.
|
||||
* Each item will be passed to getItemComponent for rendering.
|
||||
*/
|
||||
items: Item[];
|
||||
|
||||
/**
|
||||
* Function that renders each list item as a JSX element.
|
||||
* @param index - The index of the item in the list
|
||||
* @param item - The data item to render
|
||||
* @param context - The context object containing the focused key and any additional data
|
||||
* @param onFocus - A callback that is required to be called when the item component receives focus
|
||||
* @returns JSX element representing the rendered item
|
||||
*/
|
||||
getItemComponent: (
|
||||
index: number,
|
||||
item: Item,
|
||||
context: ListContext<Context>,
|
||||
onFocus: (item: Item, e: React.FocusEvent) => void,
|
||||
) => JSX.Element;
|
||||
|
||||
/**
|
||||
* Optional additional context data to pass to each rendered item.
|
||||
* This will be available in the ListContext passed to getItemComponent.
|
||||
*/
|
||||
context?: Context;
|
||||
|
||||
/**
|
||||
* Function to determine if an item can receive focus during keyboard navigation.
|
||||
* @param item - The item to check for focusability
|
||||
* @returns true if the item can be focused, false otherwise
|
||||
*/
|
||||
isItemFocusable: (item: Item) => boolean;
|
||||
|
||||
/**
|
||||
* Function to get the key to use for focusing an item.
|
||||
* @param item - The item to get the key for
|
||||
* @return The key to use for focusing the item
|
||||
*/
|
||||
getItemKey: (item: Item) => string;
|
||||
|
||||
/**
|
||||
* Callback function to handle key down events on the list container.
|
||||
* ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
|
||||
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
|
||||
* @param e - The keyboard event
|
||||
* @returns
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic virtualized list component built on top of react-virtuoso.
|
||||
* Provides keyboard navigation and virtualized rendering for performance with large lists.
|
||||
*
|
||||
* @template Item - The type of data items in the list
|
||||
* @template Context - The type of additional context data passed to items
|
||||
*/
|
||||
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
|
||||
// Extract our custom props to avoid conflicts with Virtuoso props
|
||||
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
|
||||
/** Reference to the Virtuoso component for programmatic scrolling */
|
||||
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
|
||||
/** Reference to the DOM element containing the virtualized list */
|
||||
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
|
||||
/** Key of the item that should have tabIndex == 0 */
|
||||
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
|
||||
props.items[0] ? getItemKey(props.items[0]) : undefined,
|
||||
);
|
||||
/** Range of currently visible items in the viewport */
|
||||
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
|
||||
/** Map from item keys to their indices in the items array */
|
||||
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
|
||||
/** Whether the list is currently scrolling to an item */
|
||||
const isScrollingToItem = useRef<boolean>(false);
|
||||
/** Whether the list is currently focused */
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
// Update the key-to-index mapping whenever items change
|
||||
useEffect(() => {
|
||||
const newKeyToIndexMap = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
const key = getItemKey(item);
|
||||
newKeyToIndexMap.set(key, index);
|
||||
});
|
||||
setKeyToIndexMap(newKeyToIndexMap);
|
||||
}, [items, getItemKey]);
|
||||
|
||||
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
|
||||
useEffect(() => {
|
||||
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
|
||||
setTabIndexKey(getItemKey(items[0]));
|
||||
}
|
||||
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
|
||||
|
||||
/**
|
||||
* Scrolls to a specific item index and sets it as focused.
|
||||
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
|
||||
*/
|
||||
const scrollToIndex = useCallback(
|
||||
(index: number, align?: "center" | "end" | "start"): void => {
|
||||
// Ensure index is within bounds
|
||||
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||
if (isScrollingToItem.current) {
|
||||
// If already scrolling to an item drop this request. Adding further requests
|
||||
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
|
||||
return;
|
||||
}
|
||||
if (items[clampedIndex]) {
|
||||
const key = getItemKey(items[clampedIndex]);
|
||||
isScrollingToItem.current = true;
|
||||
virtuosoHandleRef.current?.scrollIntoView({
|
||||
index: clampedIndex,
|
||||
align: align,
|
||||
behavior: "auto",
|
||||
done: () => {
|
||||
setTabIndexKey(key);
|
||||
isScrollingToItem.current = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[items, getItemKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Scrolls to an item, skipping over non-focusable items if necessary.
|
||||
* This is used for keyboard navigation to ensure focus lands on valid items.
|
||||
*/
|
||||
const scrollToItem = useCallback(
|
||||
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
|
||||
const totalRows = items.length;
|
||||
let nextIndex: number | undefined;
|
||||
|
||||
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
|
||||
if (isItemFocusable(items[i])) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToIndex(nextIndex, align);
|
||||
},
|
||||
[scrollToIndex, items, isItemFocusable],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation for the list.
|
||||
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
|
||||
*/
|
||||
const keyDownCallback = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
|
||||
let handled = false;
|
||||
|
||||
// Guard against null/undefined events and modified keys which we don't want to handle here but do
|
||||
// at the settings level shortcuts(E.g. Select next room, etc )
|
||||
// Guard against null/undefined events and modified keys
|
||||
if (!e || isModifiedKeyEvent(e)) {
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
|
||||
scrollToItem(currentIndex - 1, false);
|
||||
handled = true;
|
||||
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
|
||||
scrollToItem(currentIndex + 1, true);
|
||||
handled = true;
|
||||
} else if (e.code === Key.HOME) {
|
||||
scrollToIndex(0);
|
||||
handled = true;
|
||||
} else if (e.code === Key.END) {
|
||||
scrollToIndex(items.length - 1);
|
||||
handled = true;
|
||||
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
|
||||
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, "start");
|
||||
handled = true;
|
||||
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
|
||||
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
|
||||
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, "start");
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} else {
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
},
|
||||
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
|
||||
);
|
||||
|
||||
/**
|
||||
* Callback ref for the Virtuoso scroller element.
|
||||
* Stores the reference for use in focus management.
|
||||
*/
|
||||
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
|
||||
virtuosoDomRef.current = element;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Focus handler passed to each item component.
|
||||
* Don't declare inside getItemComponent to avoid re-creating on each render.
|
||||
*/
|
||||
const onFocusForGetItemComponent = useCallback(
|
||||
(item: Item, e: React.FocusEvent) => {
|
||||
// If one of the item components has been focused directly, set the focused and tabIndex state
|
||||
// and stop propagation so the ListViews onFocus doesn't also handle it.
|
||||
const key = getItemKey(item);
|
||||
setIsFocused(true);
|
||||
setTabIndexKey(key);
|
||||
e.stopPropagation();
|
||||
},
|
||||
[getItemKey],
|
||||
);
|
||||
|
||||
const getItemComponentInternal = useCallback(
|
||||
(index: number, item: Item, context: ListContext<Context>): JSX.Element =>
|
||||
getItemComponent(index, item, context, onFocusForGetItemComponent),
|
||||
[getItemComponent, onFocusForGetItemComponent],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles focus events on the list.
|
||||
* Sets the focused state and scrolls to the focused item if it is not currently visible.
|
||||
*/
|
||||
const onFocus = useCallback(
|
||||
(e?: React.FocusEvent): void => {
|
||||
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFocused(true);
|
||||
const index = keyToIndexMap.get(tabIndexKey);
|
||||
if (
|
||||
index !== undefined &&
|
||||
visibleRange &&
|
||||
(index < visibleRange.startIndex || index > visibleRange.endIndex)
|
||||
) {
|
||||
scrollToIndex(index);
|
||||
}
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
},
|
||||
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
|
||||
);
|
||||
|
||||
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
|
||||
// Only set isFocused to false if the focus is moving outside the list
|
||||
// This prevents the list from losing focus when interacting with menus inside it
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listContext: ListContext<Context> = {
|
||||
tabIndexKey: tabIndexKey,
|
||||
focused: isFocused,
|
||||
context: props.context || ({} as Context),
|
||||
};
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
// note that either the container of direct children must be focusable to be axe
|
||||
// compliant, so we leave tabIndex as the default so the container can be focused
|
||||
// (virtuoso wraps the children inside another couple of elements so setting it
|
||||
// on those doesn't seem to work, unfortunately)
|
||||
ref={virtuosoHandleRef}
|
||||
scrollerRef={scrollerRef}
|
||||
onKeyDown={keyDownCallback}
|
||||
context={listContext}
|
||||
rangeChanged={setVisibleRange}
|
||||
// virtuoso errors internally if you pass undefined.
|
||||
overscan={props.overscan || 0}
|
||||
data={props.items}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
itemContent={getItemComponentInternal}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
packages/shared-components/src/utils/ListView/index.ts
Normal file
9
packages/shared-components/src/utils/ListView/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { ListView, Key, isModifiedKeyEvent } from "./ListView";
|
||||
export type { IListViewProps, ListContext } from "./ListView";
|
||||
@ -6025,6 +6025,11 @@ react-merge-refs@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-3.0.2.tgz#483b4e8029f89d805c4e55c8d22e9b8f77e3b58e"
|
||||
integrity sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==
|
||||
|
||||
react-virtuoso@^4.15.0:
|
||||
version "4.15.0"
|
||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.15.0.tgz#5ce64489bb1e42944822c2ca9fff4731890bafc0"
|
||||
integrity sha512-yDVJqOXf3N1GlSoXj/hMhslA6EyUmgCUVzS9u8MpdZs/KIwdTtbQnttZ0yzx8ZKdKeVdd0NPJG4pTINHbAyhyw==
|
||||
|
||||
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0":
|
||||
version "19.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"
|
||||
|
||||
@ -5,16 +5,13 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, type JSX } from "react";
|
||||
import React, { useCallback, useMemo, type JSX } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type ScrollIntoViewLocation } from "react-virtuoso";
|
||||
import { isEqual } from "lodash";
|
||||
import { RoomList as SharedRoomList, type RoomsResult, type FilterKey } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { RoomListItemView } from "./RoomListItemView";
|
||||
import { type ListContext, ListView } from "../../../utils/ListView";
|
||||
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
|
||||
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
|
||||
@ -25,81 +22,69 @@ interface RoomListProps {
|
||||
*/
|
||||
vm: RoomListViewState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of a single room list item
|
||||
* Room adapter that wraps Matrix Room objects with an id property for the shared component
|
||||
*/
|
||||
const ROOM_LIST_ITEM_HEIGHT = 48;
|
||||
/**
|
||||
* Amount to extend the top and bottom of the viewport by.
|
||||
* From manual testing and user feedback 25 items is reported to be enough to avoid blank space when using the mouse wheel,
|
||||
* and the trackpad scrolling at a slow to moderate speed where you can still see/read the content.
|
||||
* Using the trackpad to sling through a large percentage of the list quickly will still show blank space.
|
||||
* We would likely need to simplify the item content to improve this case.
|
||||
*/
|
||||
const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
|
||||
interface RoomAdapter {
|
||||
id: string;
|
||||
room: Room;
|
||||
}
|
||||
|
||||
/**
|
||||
* A virtualized list of rooms.
|
||||
* This component adapts element-web's room list to use the shared RoomList component.
|
||||
*/
|
||||
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
|
||||
const lastSpaceId = useRef<string | undefined>(undefined);
|
||||
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
|
||||
const roomCount = roomsResult.rooms.length;
|
||||
const getItemComponent = useCallback(
|
||||
|
||||
/**
|
||||
* Adapt the element-web roomsResult to the shared component's format
|
||||
*/
|
||||
const adaptedRoomsResult: RoomsResult<RoomAdapter> = useMemo(
|
||||
() => ({
|
||||
spaceId: roomsResult.spaceId,
|
||||
filterKeys: roomsResult.filterKeys as FilterKey[] | undefined,
|
||||
rooms: roomsResult.rooms.map((room) => ({
|
||||
id: room.roomId,
|
||||
room,
|
||||
})),
|
||||
}),
|
||||
[roomsResult],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render a room item using the RoomListItemView
|
||||
*/
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
index: number,
|
||||
item: Room,
|
||||
context: ListContext<{
|
||||
spaceId: string;
|
||||
filterKeys: FilterKey[] | undefined;
|
||||
}>,
|
||||
onFocus: (item: Room, e: React.FocusEvent) => void,
|
||||
): JSX.Element => {
|
||||
const itemKey = item.roomId;
|
||||
const isRovingItem = itemKey === context.tabIndexKey;
|
||||
const isFocused = isRovingItem && context.focused;
|
||||
const isSelected = activeIndex === index;
|
||||
item: RoomAdapter,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean,
|
||||
tabIndex: number,
|
||||
roomCount: number,
|
||||
onFocus: (item: RoomAdapter, e: React.FocusEvent) => void,
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<RoomListItemView
|
||||
room={item}
|
||||
key={itemKey}
|
||||
room={item.room}
|
||||
key={item.id}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
tabIndex={isRovingItem ? 0 : -1}
|
||||
tabIndex={tabIndex}
|
||||
roomIndex={index}
|
||||
roomCount={roomCount}
|
||||
onFocus={onFocus}
|
||||
onFocus={(room, e) => onFocus(item, e)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[activeIndex, roomCount],
|
||||
);
|
||||
|
||||
const getItemKey = useCallback((item: Room): string => {
|
||||
return item.roomId;
|
||||
}, []);
|
||||
|
||||
const scrollIntoViewOnChange = useCallback(
|
||||
(params: {
|
||||
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
|
||||
}): ScrollIntoViewLocation | null | undefined | false | void => {
|
||||
const { spaceId, filterKeys } = params.context.context;
|
||||
const shouldScrollIndexIntoView =
|
||||
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
|
||||
lastFilterKeys.current = filterKeys;
|
||||
lastSpaceId.current = spaceId;
|
||||
|
||||
if (shouldScrollIndexIntoView) {
|
||||
return {
|
||||
align: `start`,
|
||||
index: activeIndex || 0,
|
||||
behavior: "auto",
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[activeIndex],
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle keyboard events for landmark navigation
|
||||
*/
|
||||
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
|
||||
const navAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
|
||||
@ -114,23 +99,12 @@ export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): J
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ListView
|
||||
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
|
||||
scrollIntoViewOnChange={scrollIntoViewOnChange}
|
||||
initialTopMostItemIndex={activeIndex}
|
||||
data-testid="room-list"
|
||||
role="listbox"
|
||||
aria-label={_t("room_list|list_title")}
|
||||
fixedItemHeight={ROOM_LIST_ITEM_HEIGHT}
|
||||
items={roomsResult.rooms}
|
||||
getItemComponent={getItemComponent}
|
||||
getItemKey={getItemKey}
|
||||
isItemFocusable={() => true}
|
||||
<SharedRoomList
|
||||
roomsResult={adaptedRoomsResult}
|
||||
activeIndex={activeIndex}
|
||||
renderItem={renderItem}
|
||||
onKeyDown={keyDownCallback}
|
||||
increaseViewportBy={{
|
||||
bottom: EXTENDED_VIEWPORT_HEIGHT,
|
||||
top: EXTENDED_VIEWPORT_HEIGHT,
|
||||
}}
|
||||
ariaLabel={_t("room_list|list_title")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,10 +5,9 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { type JSX, memo, useCallback, type ReactNode } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
import { RoomListItem as SharedRoomListItem } from "@element-hq/web-shared-components";
|
||||
|
||||
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
|
||||
import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
||||
@ -44,120 +43,66 @@ interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElem
|
||||
}
|
||||
|
||||
/**
|
||||
* An item in the room list
|
||||
* An item in the room list.
|
||||
* This component wraps the shared RoomListItem and provides element-web specific
|
||||
* implementations for the avatar, notifications, and menus.
|
||||
*/
|
||||
export const RoomListItemView = memo(function RoomListItemView({
|
||||
room,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onFocus,
|
||||
roomIndex: index,
|
||||
roomCount: count,
|
||||
roomIndex,
|
||||
roomCount,
|
||||
...props
|
||||
}: RoomListItemViewProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const vm = useRoomListItemViewModel(room);
|
||||
const [isHover, setHover] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
|
||||
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
|
||||
const showHoverDecoration = isMenuOpen || isFocused || isHover;
|
||||
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
// To avoid icon blinking when closing the menu, we delay the state update
|
||||
// Also, let the focus move to the menu trigger before closing the menu
|
||||
setTimeout(() => setIsMenuOpen(false), 10);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
ref.current?.focus({ preventScroll: true, focusVisible: true });
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const content = (
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
className={classNames("mx_RoomListItemView", {
|
||||
mx_RoomListItemView_hover: showHoverDecoration,
|
||||
mx_RoomListItemView_menu_open: showHoverMenu,
|
||||
mx_RoomListItemView_selected: isSelected,
|
||||
mx_RoomListItemView_bold: vm.isBold,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
type="button"
|
||||
role="option"
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={count}
|
||||
aria-selected={isSelected}
|
||||
aria-label={vm.a11yLabel}
|
||||
onClick={() => vm.openRoom()}
|
||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(room, e)}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onBlur={() => setHover(false)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
{...props}
|
||||
>
|
||||
<RoomAvatarView room={room} />
|
||||
<Flex
|
||||
className="mx_RoomListItemView_content"
|
||||
gap="var(--cpd-space-2x)"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className="mx_RoomListItemView_text">
|
||||
<div className="mx_RoomListItemView_roomName" title={vm.name}>
|
||||
{vm.name}
|
||||
</div>
|
||||
{vm.messagePreview && (
|
||||
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
|
||||
{vm.messagePreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showHoverMenu ? (
|
||||
<RoomListItemMenuView
|
||||
room={room}
|
||||
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
|
||||
{vm.showNotificationDecoration && (
|
||||
<NotificationDecoration
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
callType={vm.callType}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
// Wrap onFocus to include the room parameter
|
||||
const handleFocus = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
onFocus(room, e);
|
||||
},
|
||||
[onFocus, room],
|
||||
);
|
||||
|
||||
// Rendering multiple context menus can causes crashes in radix upstream,
|
||||
// See https://github.com/radix-ui/primitives/issues/2717.
|
||||
if (!vm.showContextMenu) return content;
|
||||
// Create the avatar component
|
||||
const avatar = <RoomAvatarView room={room} />;
|
||||
|
||||
// Create the notification decoration component
|
||||
const notificationDecoration = vm.showNotificationDecoration ? (
|
||||
<NotificationDecoration
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
callType={vm.callType}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Create the hover menu component
|
||||
const hoverMenu = vm.showHoverMenu ? <RoomListItemMenuView room={room} setMenuOpen={() => {}} /> : null;
|
||||
|
||||
// Create the context menu wrapper function
|
||||
const contextMenuWrapper = vm.showContextMenu
|
||||
? (content: ReactNode) => (
|
||||
<RoomListItemContextMenuView room={room} setMenuOpen={() => {}}>
|
||||
{content}
|
||||
</RoomListItemContextMenuView>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<RoomListItemContextMenuView
|
||||
room={room}
|
||||
setMenuOpen={(isOpen) => {
|
||||
if (isOpen) {
|
||||
// To avoid icon blinking when the context menu is re-opened
|
||||
setTimeout(() => setIsMenuOpen(true), 0);
|
||||
} else {
|
||||
closeMenu();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</RoomListItemContextMenuView>
|
||||
<SharedRoomListItem
|
||||
viewModel={vm}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
onFocus={handleFocus}
|
||||
roomIndex={roomIndex}
|
||||
roomCount={roomCount}
|
||||
avatar={avatar}
|
||||
notificationDecoration={notificationDecoration}
|
||||
hoverMenu={hoverMenu}
|
||||
contextMenuWrapper={contextMenuWrapper}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
import { RoomListPanel as SharedRoomListPanel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../../settings/UIFeature";
|
||||
@ -59,19 +59,14 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="nav"
|
||||
className="mx_RoomListPanel"
|
||||
direction="column"
|
||||
align="stretch"
|
||||
aria-label={_t("room_list|list_title")}
|
||||
<SharedRoomListPanel
|
||||
ariaLabel={_t("room_list|list_title")}
|
||||
searchSlot={displayRoomSearch ? <RoomListSearch activeSpace={activeSpace} /> : undefined}
|
||||
headerSlot={<RoomListHeaderView />}
|
||||
contentSlot={<RoomListView />}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
|
||||
<RoomListHeaderView />
|
||||
<RoomListView />
|
||||
</Flex>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
24
yarn.lock
24
yarn.lock
@ -1579,15 +1579,8 @@
|
||||
yaml "^2.7.0"
|
||||
|
||||
"@element-hq/web-shared-components@link:packages/shared-components":
|
||||
version "0.0.0-test.8"
|
||||
dependencies:
|
||||
classnames "^2.5.1"
|
||||
counterpart "^0.18.6"
|
||||
lodash "^4.17.21"
|
||||
matrix-web-i18n "^3.4.0"
|
||||
patch-package "^8.0.1"
|
||||
react-merge-refs "^3.0.2"
|
||||
temporal-polyfill "^0.3.0"
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@emnapi/core@^1.4.3", "@emnapi/core@^1.5.0":
|
||||
version "1.7.0"
|
||||
@ -4020,7 +4013,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.1.tgz#4c5479538ec10b5508b8e982e172911c987446d8"
|
||||
integrity sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==
|
||||
|
||||
"@typescript-eslint/types@8.48.0", "@typescript-eslint/types@^8.48.0":
|
||||
"@typescript-eslint/types@8.48.0", "@typescript-eslint/types@^8.47.0", "@typescript-eslint/types@^8.48.0":
|
||||
version "8.48.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.48.0.tgz#f0dc5cf27217346e9b0d90556911e01d90d0f2a5"
|
||||
integrity sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==
|
||||
@ -4030,11 +4023,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
|
||||
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
|
||||
|
||||
"@typescript-eslint/types@^8.47.0":
|
||||
version "8.48.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.48.0.tgz#f0dc5cf27217346e9b0d90556911e01d90d0f2a5"
|
||||
integrity sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz#1c146573b942ebe609c156c217ceafdc7a88e6ed"
|
||||
@ -4231,6 +4219,7 @@
|
||||
|
||||
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@vector-im/matrix-wysiwyg@2.40.0":
|
||||
version "2.40.0"
|
||||
@ -11621,6 +11610,11 @@ react-virtuoso@^4.14.0:
|
||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.14.1.tgz#78a5e796a3f9ec501499f01962ec6fc7eed77d8d"
|
||||
integrity sha512-NRUF1ak8lY+Tvc6WN9cce59gU+lilzVtOozP+pm9J7iHshLGGjsiAB4rB2qlBPHjFbcXOQpT+7womNHGDUql8w==
|
||||
|
||||
react-virtuoso@^4.15.0:
|
||||
version "4.15.0"
|
||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.15.0.tgz#5ce64489bb1e42944822c2ca9fff4731890bafc0"
|
||||
integrity sha512-yDVJqOXf3N1GlSoXj/hMhslA6EyUmgCUVzS9u8MpdZs/KIwdTtbQnttZ0yzx8ZKdKeVdd0NPJG4pTINHbAyhyw==
|
||||
|
||||
react@^19.0.0:
|
||||
version "19.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user