diff --git a/packages/shared-components/jest-sonar.xml b/packages/shared-components/jest-sonar.xml new file mode 100644 index 0000000000..88ef6de10f --- /dev/null +++ b/packages/shared-components/jest-sonar.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 023c708a04..ed3fd20320 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -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": { diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 68935afd3f..a77c4b53ce 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/notifications/NotificationDecoration/NotificationDecoration.tsx b/packages/shared-components/src/notifications/NotificationDecoration/NotificationDecoration.tsx new file mode 100644 index 0000000000..89b9946ae6 --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationDecoration/NotificationDecoration.tsx @@ -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 = ({ viewModel }) => { + // Don't render anything if there's nothing to show + if (!viewModel.hasAnyNotificationOrActivity && !viewModel.muted && !viewModel.callType) { + return null; + } + + return ( + + {viewModel.isUnsentMessage && ( + + )} + {viewModel.callType === "video" && ( + + )} + {viewModel.callType === "voice" && ( + + )} + {viewModel.invited && } + {viewModel.isMention && ( + + )} + {(viewModel.isMention || viewModel.isNotification) && } + {viewModel.isActivityNotification && } + {viewModel.muted && ( + + )} + + ); +}; diff --git a/packages/shared-components/src/notifications/NotificationDecoration/index.tsx b/packages/shared-components/src/notifications/NotificationDecoration/index.tsx new file mode 100644 index 0000000000..7d14b7d44d --- /dev/null +++ b/packages/shared-components/src/notifications/NotificationDecoration/index.tsx @@ -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"; diff --git a/packages/shared-components/src/notifications/RoomNotifs.ts b/packages/shared-components/src/notifications/RoomNotifs.ts new file mode 100644 index 0000000000..b11e80d672 --- /dev/null +++ b/packages/shared-components/src/notifications/RoomNotifs.ts @@ -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", +} diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.module.css b/packages/shared-components/src/room-list/RoomList/RoomList.module.css new file mode 100644 index 0000000000..867fbf19a0 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.module.css @@ -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%; +} diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx new file mode 100644 index 0000000000..39eb65d00f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.stories.tsx @@ -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 => ( +
+ {name.substring(0, 2).toUpperCase()} +
+); + +// 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) => ( +
+ +
+ ), + ], + args: { + viewModel: mockViewModel, + renderAvatar, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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: [], + }, + }, + }, +}; diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx new file mode 100644 index 0000000000..d6ad5b9289 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.test.tsx @@ -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) => ( +
{roomViewModel.name[0]}
+ )); + + const mockViewModel: RoomListViewModel = { + roomsResult: mockRoomsResult, + activeRoomIndex: undefined, + onKeyDown: undefined, + }; + + beforeEach(() => { + mockRenderAvatar.mockClear(); + }); + + it("renders the room list with correct aria attributes", () => { + render(); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + expect(listbox).toHaveAttribute("data-testid", "room-list"); + }); + + it("renders with correct aria-label", () => { + render(); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + expect(listbox).toHaveAttribute("aria-label"); + }); + + it("calls renderAvatar for each room", () => { + render(); + + // 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(); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + }); + + it("passes activeRoomIndex correctly", () => { + const vmWithActive: RoomListViewModel = { + ...mockViewModel, + activeRoomIndex: 1, + }; + + render(); + + // 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(); + + 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(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomList/RoomList.tsx b/packages/shared-components/src/room-list/RoomList/RoomList.tsx new file mode 100644 index 0000000000..16a51bf54f --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/RoomList.tsx @@ -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(undefined); + const lastFilterKeys = useRef(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 ( +
+ onFocus(item, e)} + roomIndex={index} + roomCount={roomCount} + avatar={renderAvatar(item)} + /> +
+ ); + }, + [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 ( + true} + onKeyDown={keyDownCallback} + increaseViewportBy={{ + bottom: EXTENDED_VIEWPORT_HEIGHT, + top: EXTENDED_VIEWPORT_HEIGHT, + }} + /> + ); +} diff --git a/packages/shared-components/src/room-list/RoomList/index.ts b/packages/shared-components/src/room-list/RoomList/index.ts new file mode 100644 index 0000000000..33a97dcd19 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomList/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx new file mode 100644 index 0000000000..23395a3bb8 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/ComposeMenu.tsx @@ -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 = ({ viewModel }): JSX.Element => { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + + {viewModel.canCreateRoom && ( + + )} + {viewModel.canCreateVideoRoom && ( + + )} + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.module.css b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.module.css new file mode 100644 index 0000000000..0ffb00a904 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.module.css @@ -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; +} diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx new file mode 100644 index 0000000000..aade22b00b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.stories.tsx @@ -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 = { + title: "Room List/RoomListHeader", + component: RoomListHeader, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +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) => ( +
+ +
+ ), + ], +}; diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx new file mode 100644 index 0000000000..2a50a76e04 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.test.tsx @@ -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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + 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(); + + expect(screen.getByTestId("room-list-header")).toBeInTheDocument(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx new file mode 100644 index 0000000000..bdce8498d1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/RoomListHeader.tsx @@ -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 = ({ viewModel }): JSX.Element => { + return ( + + +

{viewModel.title}

+ {viewModel.isSpace && viewModel.spaceMenuViewModel && ( + + )} +
+ + + {viewModel.displayComposeMenu && viewModel.composeMenuViewModel ? ( + + ) : ( + + + + )} + +
+ ); +}; diff --git a/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx new file mode 100644 index 0000000000..bb95404303 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/SortOptionsMenu.tsx @@ -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): JSX.Element => ( + + + + + +); + +/** + * 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 = ({ 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 ( + } + > + + + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx b/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx new file mode 100644 index 0000000000..5549ddc640 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/SpaceMenu.tsx @@ -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 = ({ viewModel }): JSX.Element => { + const [open, setOpen] = useState(false); + + return ( + + + + } + > + + {viewModel.canInviteInSpace && ( + + )} + + {viewModel.canAccessSpaceSettings && ( + + )} + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListHeader/index.tsx b/packages/shared-components/src/room-list/RoomListHeader/index.tsx new file mode 100644 index 0000000000..ad3dab5564 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListHeader/index.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css new file mode 100644 index 0000000000..f312d3d048 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.module.css @@ -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); +} diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx new file mode 100644 index 0000000000..55126897b2 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.stories.tsx @@ -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 = ( +
+ TR +
+); + +// 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) => ( +
+ +
+ ), + ], + args: { + viewModel: baseViewModel, + isSelected: false, + isFocused: false, + onFocus: () => {}, + roomIndex: 0, + roomCount: 10, + avatar: mockAvatar, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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 => ( +
+ {}} + roomIndex={0} + roomCount={5} + avatar={mockAvatar} + /> + {}} + roomIndex={1} + roomCount={5} + avatar={mockAvatar} + /> + {}} + roomIndex={2} + roomCount={5} + avatar={mockAvatar} + /> + {}} + roomIndex={3} + roomCount={5} + avatar={mockAvatar} + /> + {}} + roomIndex={4} + roomCount={5} + avatar={mockAvatar} + /> +
+ ), +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx new file mode 100644 index 0000000000..f7d182f141 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.test.tsx @@ -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 =
Avatar
; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders room name and avatar", () => { + render( + , + ); + + 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( + , + ); + + expect(screen.getByText("Latest message preview")).toBeInTheDocument(); + }); + + it("applies selected styles when selected", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole("option")); + expect(mockViewModel.openRoom).toHaveBeenCalledTimes(1); + }); + + it("calls onFocus when focused", async () => { + const onFocus = jest.fn(); + render( + , + ); + + 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( + , + ); + + expect(screen.getByTestId("notification-decoration")).toBeInTheDocument(); + }); + + it("sets correct ARIA attributes", () => { + render( + , + ); + + 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); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx new file mode 100644 index 0000000000..cdabaa7cbb --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItem.tsx @@ -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, "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(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 = ( + viewModel.openRoom()} + onFocus={onFocus} + onMouseOver={() => setHover(true)} + onMouseOut={() => setHover(false)} + onBlur={() => setHover(false)} + tabIndex={isFocused ? 0 : -1} + {...props} + > + {avatar} + + {/* We truncate the room name when too long. Title here is to show the full name on hover */} +
+
+ {viewModel.name} +
+ {viewModel.messagePreview && ( +
+ {viewModel.messagePreview} +
+ )} +
+ {showHoverMenu ? ( + (isOpen ? setIsMenuOpen(true) : closeMenu())} + /> + ) : ( + <> + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} +
+ +
+ + )} +
+
+ ); + + return ( + + {content} + + ); +}); diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx new file mode 100644 index 0000000000..cd02d59b71 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemContextMenu.tsx @@ -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> = ({ + viewModel, + onMenuOpenChange, + children, +}): JSX.Element => { + return ( + + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx new file mode 100644 index 0000000000..a829ff1abf --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemHoverMenu.tsx @@ -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 = ({ + viewModel, + onMenuOpenChange, +}): JSX.Element => { + return ( + + {viewModel.showMoreOptionsMenu && ( + + )} + {viewModel.showNotificationMenu && ( + + )} + + ); +}; + +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 ( + } + > + + + ); +} + +interface MoreOptionContentProps { + viewModel: RoomListItemMenuViewModel; +} + +export function MoreOptionContent({ viewModel }: MoreOptionContentProps): JSX.Element { + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + {viewModel.canMarkAsRead && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {viewModel.canMarkAsUnread && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + evt.stopPropagation()} + /> + evt.stopPropagation()} + /> + {viewModel.canInvite && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + {viewModel.canCopyRoomLink && ( + evt.stopPropagation()} + hideChevron={true} + /> + )} + + evt.stopPropagation()} + hideChevron={true} + /> +
+ ); +} + +const MoreOptionsButton = function MoreOptionsButton(props: ComponentProps): JSX.Element { + return ( + + + + + + ); +}; + +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 = ; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + } + > + viewModel.setRoomNotifState(RoomNotifState.AllMessages)} + onClick={(evt) => evt.stopPropagation()} + > + {viewModel.isNotificationAllMessage && checkComponent} + + viewModel.setRoomNotifState(RoomNotifState.AllMessagesLoud)} + onClick={(evt) => evt.stopPropagation()} + > + {viewModel.isNotificationAllMessageLoud && checkComponent} + + viewModel.setRoomNotifState(RoomNotifState.MentionsOnly)} + onClick={(evt) => evt.stopPropagation()} + > + {viewModel.isNotificationMentionOnly && checkComponent} + + viewModel.setRoomNotifState(RoomNotifState.Mute)} + onClick={(evt) => evt.stopPropagation()} + > + {viewModel.isNotificationMute && checkComponent} + + +
+ ); +} + +interface NotificationButtonProps extends ComponentProps { + isRoomMuted: boolean; +} + +const NotificationButton = function NotificationButton({ + isRoomMuted, + ...props +}: NotificationButtonProps): JSX.Element { + return ( + + + {isRoomMuted ? : } + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListItem/RoomListItemMenuViewModel.ts b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMenuViewModel.ts new file mode 100644 index 0000000000..167dd76c11 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/RoomListItemMenuViewModel.ts @@ -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; +} diff --git a/packages/shared-components/src/room-list/RoomListItem/index.ts b/packages/shared-components/src/room-list/RoomListItem/index.ts new file mode 100644 index 0000000000..748211e1d0 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListItem/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.module.css b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.module.css new file mode 100644 index 0000000000..5feef75b07 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.module.css @@ -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); +} diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx new file mode 100644 index 0000000000..857364cde5 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.stories.tsx @@ -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 => ( +
+ {roomViewModel.name.substring(0, 2).toUpperCase()} +
+); + +// 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 = { + title: "Room List/RoomListPanel", + component: RoomListPanel, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +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) => ( +
+ +
+ ), + ], +}; + +export const WithoutSearch: Story = { + args: { + viewModel: { + ...baseViewModel, + searchViewModel: undefined, + }, + renderAvatar: mockAvatar, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Loading: Story = { + args: { + viewModel: { + ...baseViewModel, + viewViewModel: { + ...baseViewModel.viewViewModel, + isLoadingRooms: true, + }, + }, + renderAvatar: mockAvatar, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +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) => ( +
+ +
+ ), + ], +}; diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx new file mode 100644 index 0000000000..6be764efcf --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.test.tsx @@ -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) => ( +
{roomViewModel.name[0]}
+ )); + + 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(); + + expect(screen.getByText("Test Header")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Room List" })).toBeInTheDocument(); + }); + + it("renders without search", () => { + const vmWithoutSearch = { + ...mockViewModel, + searchViewModel: undefined, + }; + + render(); + + expect(screen.getByText("Test Header")).toBeInTheDocument(); + }); + + it("renders loading state", () => { + const vmLoading: RoomListPanelViewModel = { + ...mockViewModel, + viewViewModel: { + ...mockViewModel.viewViewModel, + isLoadingRooms: true, + isRoomListEmpty: false, + }, + }; + + render(); + + // 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 should render (empty state is handled by RoomListView) + expect(screen.getByRole("navigation")).toBeInTheDocument(); + }); + + it("passes additional HTML attributes", () => { + render(); + + expect(screen.getByTestId("custom-panel")).toBeInTheDocument(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx new file mode 100644 index 0000000000..8c3576290e --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/RoomListPanel.tsx @@ -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 { + /** 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 = ({ viewModel, renderAvatar, ...props }): JSX.Element => { + return ( + + {viewModel.searchViewModel && } + + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListPanel/index.tsx b/packages/shared-components/src/room-list/RoomListPanel/index.tsx new file mode 100644 index 0000000000..e6a2d37f7a --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPanel/index.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css new file mode 100644 index 0000000000..c287e9858b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.module.css @@ -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); +} diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx new file mode 100644 index 0000000000..64b61597c3 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.stories.tsx @@ -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 = { + title: "Room List/RoomListPrimaryFilters", + component: RoomListPrimaryFilters, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +// 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"), + }, + ], + }, + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx new file mode 100644 index 0000000000..1f027f9994 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx @@ -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 = ({ viewModel }): JSX.Element => { + const id = useId(); + const [isExpanded, setIsExpanded] = useState(false); + + const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters(isExpanded); + const filters = useVisibleFilters(viewModel.filters, wrappingIndex); + + return ( + + {displayChevron && ( + setIsExpanded((expanded) => !expanded)} + > + + + )} + + {filters.map((filter, i) => ( + filter.toggle()}> + {filter.name} + + ))} + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx.bak b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx.bak new file mode 100644 index 0000000000..3b518cb5c7 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/RoomListPrimaryFilters.tsx.bak @@ -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; +} + +/** + * 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 = ({ viewModel }): JSX.Element => { + return ( + + {viewModel.displayChevron && viewModel.chevronButton} + + {viewModel.filterElements.map((element, i) => ( + {element} + ))} + + + ); +}; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx new file mode 100644 index 0000000000..b625b7ab12 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/index.tsx @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts new file mode 100644 index 0000000000..0f1163b3df --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useCollapseFilters.ts @@ -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( + isExpanded: boolean, +): { + ref: RefObject; + isWrapping: boolean; + wrappingIndex: number; +} { + const ref = useRef(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 }; +} diff --git a/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts new file mode 100644 index 0000000000..b68175a532 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListPrimaryFilters/useVisibleFilters.ts @@ -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; +} diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.module.css b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.module.css new file mode 100644 index 0000000000..90303e62a0 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.module.css @@ -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; +} diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx new file mode 100644 index 0000000000..e67d60b8e6 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.stories.tsx @@ -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 = { + title: "Room List/RoomListSearch", + component: RoomListSearch, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +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"), + }, + }, +}; diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx new file mode 100644 index 0000000000..99b6e6ad25 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx new file mode 100644 index 0000000000..2e6a6c4d1b --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearch/RoomListSearch.tsx @@ -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 = ({ 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 ( + + + {viewModel.showDialPad && ( +