Room list: add sections to shared components (#32735)

* feat: add section header

* refactor: remove index and role related to list box from RoomListItemView

* feat: add wrapper to RoomListItemView to handle different accessiblity pattern

* feat: add section support to VirtualizedRoomListView

* feat: add sections support to RoomListView

* test: add screenshot for sections header

* test: add/update screenshots for sections

* feat: force flat list on view model

This is an intermediary step before implementing sections in the vm. We
force the flat list but we use the underneath the view supporting
sections.

* test: update RoomListViewModel test

* test: fix breaking test

* chore: rename `getSectionViewModel` to `getSectionHeaderViewModel`

* chore: add missing `RoomListItemAccessibilityWrapper` export

* chore: merge `react` imports

* chore: simplify and add comment to `getItemKey` and `getHeaderKey`

* chore add comments to `getItemComponent` variants

* chore: fix typo in example doc
This commit is contained in:
Florian Duros
2026-03-16 17:40:12 +01:00
committed by GitHub
parent d18aa31d7d
commit ee5d2609df
41 changed files with 7505 additions and 1700 deletions
@@ -61,6 +61,8 @@ export class RoomListViewViewModel
const roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(undefined);
const canCreateRoom = hasCreateRoomRights(props.client, activeSpace);
const filterIds = [...filterKeyToIdMap.values()];
const roomIds = roomsResult.rooms.map((room) => room.roomId);
const sections = [{ id: "all", roomIds }];
super(props, {
// Initial view state - start with empty, will populate in async init
@@ -73,7 +75,9 @@ export class RoomListViewViewModel
spaceId: roomsResult.spaceId,
filterKeys: undefined,
},
roomIds: roomsResult.rooms.map((room) => room.roomId),
// Until we implement sections, this view model only supports the flat list mode
isFlatList: true,
sections,
canCreateRoom,
});
@@ -195,6 +199,15 @@ export class RoomListViewViewModel
return viewModel;
}
/**
* Not implemented - this view model does not support sections.
* Flat list mode is forced so this method is never be called.
* @throw Error if called
*/
public getSectionHeaderViewModel(): never {
throw new Error("Sections are not supported in this room list");
}
/**
* Update which rooms are currently visible.
* Called by the view when scroll position changes.
@@ -408,6 +421,7 @@ export class RoomListViewViewModel
// Build the complete state atomically to ensure consistency
// roomIds and roomListState must always be in sync
const roomIds = this.roomIds;
const sections = [{ id: "all", roomIds }];
// Update filter keys - only update if they have actually changed to prevent unnecessary re-renders of the room list
const previousFilterKeys = this.snapshot.current.roomListState.filterKeys;
@@ -428,7 +442,7 @@ export class RoomListViewViewModel
isRoomListEmpty,
activeFilterId,
roomListState,
roomIds,
sections,
});
}
@@ -66,7 +66,7 @@ describe("RoomListViewViewModel", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
expect(snapshot.sections[0].roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
expect(snapshot.isRoomListEmpty).toBe(false);
expect(snapshot.isLoadingRooms).toBe(false);
expect(snapshot.roomListState.spaceId).toBe("home");
@@ -82,7 +82,7 @@ describe("RoomListViewViewModel", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().roomIds).toEqual([]);
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([]);
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
});
@@ -106,7 +106,7 @@ describe("RoomListViewViewModel", () => {
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomIds).toEqual([
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([
"!room1:server",
"!room2:server",
"!room3:server",
@@ -156,7 +156,7 @@ describe("RoomListViewViewModel", () => {
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server"]);
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual(["!room1:server", "!room2:server"]);
});
it("should clear view models when space changes", () => {
@@ -240,7 +240,7 @@ describe("RoomListViewViewModel", () => {
// Active room should still be at index 1 (sticky behavior)
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
expect(viewModel.getSnapshot().roomIds[1]).toBe("!room2:server");
expect(viewModel.getSnapshot().sections[0].roomIds[1]).toBe("!room2:server");
});
it("should not apply sticky behavior when user changes rooms", async () => {
@@ -283,7 +283,7 @@ describe("RoomListViewViewModel", () => {
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server"]);
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual(["!room1:server"]);
});
it("should toggle filter off", () => {
@@ -307,7 +307,11 @@ describe("RoomListViewViewModel", () => {
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([
"!room1:server",
"!room2:server",
"!room3:server",
]);
});
});
@@ -125,6 +125,9 @@
"more_options": "More Options"
},
"room_options": "Room Options",
"section_header": {
"toggle": "Toggle %(section)s section"
},
"show_message_previews": "Show message previews",
"sort": "Sort",
"sort_type": {
+2
View File
@@ -37,8 +37,10 @@ export * from "./rich-list/RichItem";
export * from "./rich-list/RichList";
export * from "./room-list/RoomListHeaderView";
export * from "./room-list/RoomListSearchView";
export * from "./room-list/RoomListSectionHeaderView";
export * from "./room-list/RoomListView";
export * from "./room-list/RoomListItemView";
export * from "./room-list/RoomListItemAccessibilityWrapper";
export * from "./room-list/RoomListPrimaryFilters";
export * from "./room-list/VirtualizedRoomListView";
export * from "./timeline/DateSeparatorView/";
@@ -0,0 +1,67 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWrapper";
import { createMockRoomItemViewModel, renderAvatar } from "../story-mocks";
const meta = {
title: "Room List/RoomListItemAccessibiltyWrapper",
component: RoomListItemAccessibilityWrapper,
tags: ["autodocs"],
args: {
roomIndex: 0,
roomIndexInSection: 0,
roomCount: 10,
onFocus: fn(),
isFirstItem: false,
isLastItem: false,
renderAvatar,
isSelected: false,
isFocused: false,
vm: createMockRoomItemViewModel("!room:server", "Room name", 0),
},
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof RoomListItemAccessibilityWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FlatList: Story = {
args: {
isInFlatList: true,
},
decorators: [
(Story) => (
<div role="listbox" aria-label="Room list">
<Story />
</div>
),
],
};
export const Sections: Story = {
args: {
isInFlatList: false,
},
decorators: [
(Story) => (
<div role="treegrid" aria-label="Room list" aria-rowcount={10}>
<Story />
</div>
),
],
};
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { memo, type JSX } from "react";
import { RoomListItemView, type RoomListItemViewProps } from "../RoomListItemView";
import { getItemAccessibleProps } from "../../utils/VirtualizedList";
export interface RoomListItemAccessibilityWrapperPros extends RoomListItemViewProps {
/** Index of this room in the list */
roomIndex: number;
/** Index of this room in its section */
roomIndexInSection: number;
/** Total number of rooms in the list */
roomCount: number;
/** Whether the room list is displayed as a flat list */
isInFlatList: boolean;
}
/**
* Wrapper around RoomListItemView that adds accessibility props based on the room's position in the list and whether the list is flat or grouped.
* In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props.
*
* @example
* ``
* <RoomListItemAccessibilityWrapper
* roomIndex={0}
* roomIndexInSection={0}
* roomCount={10}
* isInFlatList={true}
* {...otherRoomListItemViewProps}
* />
* ```
*/
export const RoomListItemAccessibilityWrapper = memo(function RoomListItemAccessibilityWrapper({
roomIndex,
roomCount,
roomIndexInSection,
isInFlatList,
...rest
}: RoomListItemAccessibilityWrapperPros): JSX.Element {
const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" };
const item = <RoomListItemView {...rest} {...itemA11yProps} />;
if (isInFlatList) return item;
return <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>{item}</div>;
});
@@ -0,0 +1,8 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListItemAccessibilityWrapper } from "./RoomListItemAccessibilityWrapper";
@@ -15,14 +15,15 @@ import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { defaultSnapshot } from "./default-snapshot";
import { renderAvatar } from "../story-mocks";
import { mockedActions } from "./mocked-actions";
type RoomListItemProps = RoomListItemSnapshot &
RoomListItemActions & {
isSelected: boolean;
isFocused: boolean;
onFocus: (room: Room, e: React.FocusEvent) => void;
roomIndex: number;
roomCount: number;
isFirstItem: boolean;
isLastItem: boolean;
renderAvatar: (room: Room) => React.ReactElement;
};
@@ -40,8 +41,8 @@ const RoomListItemWrapperImpl = ({
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
isFirstItem,
isLastItem,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemProps): JSX.Element => {
@@ -62,9 +63,10 @@ const RoomListItemWrapperImpl = ({
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={roomIndex}
roomCount={roomCount}
isFirstItem={isFirstItem}
isLastItem={isLastItem}
renderAvatar={renderAvatarProp}
role="option"
/>
);
};
@@ -76,28 +78,18 @@ const meta = {
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<div role="listbox" aria-label="Room list">
<Story />
</div>
<div role="listbox" aria-label="Room list" style={{ width: "320px", padding: "8px" }}>
<Story />
</div>
),
],
args: {
...defaultSnapshot,
...mockedActions,
isSelected: false,
isFocused: false,
roomIndex: 1,
roomCount: 10,
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
isFirstItem: false,
isLastItem: false,
onFocus: fn(),
renderAvatar,
},
@@ -263,14 +255,14 @@ export const WithZoom: Story = {
export const FirstItem: Story = {
args: {
roomIndex: 0,
isFirstItem: true,
isSelected: true,
},
};
export const LastItem: Story = {
args: {
roomIndex: 9,
isLastItem: true,
isSelected: true,
},
};
@@ -119,10 +119,10 @@ export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLBut
isFocused: boolean;
/** Callback when item receives focus */
onFocus: (roomId: string, e: React.FocusEvent) => void;
/** Index of this room in the list (for accessibility) */
roomIndex: number;
/** Total number of rooms in the list (for accessibility) */
roomCount: number;
/** Whether this is the first item in the list */
isFirstItem: boolean;
/** Whether this is the last item in the list */
isLastItem: boolean;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
}
@@ -136,8 +136,8 @@ export const RoomListItemView = memo(function RoomListItemView({
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
isFirstItem,
isLastItem,
renderAvatar,
...props
}: RoomListItemViewProps): JSX.Element {
@@ -153,60 +153,57 @@ export const RoomListItemView = memo(function RoomListItemView({
// Generate a11y label from notification state and room name
const a11yLabel = getA11yLabel(item.name, item.notification);
const content = (
<Flex
as="button"
ref={ref}
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
[styles.selected]: isSelected,
[styles.bold]: item.isBold,
[styles.firstItem]: roomIndex === 0,
[styles.lastItem]: roomIndex === roomCount - 1,
mx_RoomListItemView_selected: isSelected,
})}
gap="var(--cpd-space-3x)"
align="stretch"
type="button"
role="option"
aria-posinset={roomIndex + 1}
aria-setsize={roomCount}
aria-selected={isSelected}
aria-label={a11yLabel}
onClick={vm.onOpenRoom}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
{renderAvatar(item.room)}
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.ellipsis}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
return (
<RoomListItemContextMenu vm={vm}>
<Flex
as="button"
ref={ref}
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
[styles.selected]: isSelected,
[styles.bold]: item.isBold,
[styles.firstItem]: isFirstItem,
[styles.lastItem]: isLastItem,
mx_RoomListItemView_selected: isSelected,
})}
gap="var(--cpd-space-3x)"
align="stretch"
type="button"
aria-selected={isSelected}
aria-label={a11yLabel}
onClick={vm.onOpenRoom}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
tabIndex={isFocused ? 0 : -1}
{...props}
>
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
{renderAvatar(item.room)}
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.ellipsis}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
</div>
{item.messagePreview && (
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
{item.messagePreview}
</Text>
)}
</div>
{item.messagePreview && (
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
{item.messagePreview}
</Text>
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
<RoomListItemHoverMenu
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
vm={vm}
/>
)}
</div>
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
<RoomListItemHoverMenu
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
vm={vm}
/>
)}
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...item.notification} />
</div>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...item.notification} />
</div>
</Flex>
</Flex>
</Flex>
</Flex>
</RoomListItemContextMenu>
);
return <RoomListItemContextMenu vm={vm}>{content}</RoomListItemContextMenu>;
});
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { fn } from "storybook/test";
import { type RoomListItemActions } from "./RoomListItemView";
export const mockedActions: RoomListItemActions = {
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
};
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.header {
/* Remove button default style */
background: unset;
border: none;
text-align: unset;
width: 100%;
cursor: pointer;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
padding: var(--cpd-space-1x) 0;
background-color: var(--cpd-color-bg-canvas-default);
&:hover,
&:focus-visible {
color: var(--cpd-color-text-primary);
svg {
fill: var(--cpd-color-icon-primary);
}
.container {
background-color: var(--cpd-color-bg-action-tertiary-hovered);
}
}
svg {
transition: transform 0.05s linear;
}
@media (prefers-reduced-motion: reduce) {
svg {
transition: none;
}
}
&[aria-expanded="true"] {
svg {
transform: rotate(90deg);
}
}
}
.container {
margin: 0 var(--cpd-space-3x);
padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x);
border-radius: 8px;
svg {
flex-shrink: 0;
}
}
.title {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.firstHeader {
padding-top: 0;
}
.lastHeader {
padding-bottom: 0;
}
@@ -0,0 +1,109 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
RoomListSectionHeaderView,
type RoomListSectionHeaderViewSnapshot,
type RoomListSectionHeaderActions,
type RoomListSectionHeaderViewProps,
} from "./RoomListSectionHeaderView";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
type RoomListSectionHeaderProps = RoomListSectionHeaderViewSnapshot &
RoomListSectionHeaderActions &
Omit<RoomListSectionHeaderViewProps, "vm">;
const RoomListSectionHeaderViewWrapperImpl = ({
onClick,
onFocus,
isFocused,
sectionIndex,
sectionCount,
indexInList,
roomCountInSection,
...rest
}: RoomListSectionHeaderProps): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick });
return (
<RoomListSectionHeaderView
vm={vm}
onFocus={onFocus}
isFocused={isFocused}
sectionIndex={sectionIndex}
sectionCount={sectionCount}
indexInList={indexInList}
roomCountInSection={roomCountInSection}
/>
);
};
const RoomListSectionHeaderViewWrapper = withViewDocs(RoomListSectionHeaderViewWrapperImpl, RoomListSectionHeaderView);
const meta = {
title: "Room List/RoomListSectionHeaderView",
component: RoomListSectionHeaderViewWrapper,
tags: ["autodocs"],
args: {
id: "favourites",
title: "Favourites",
isExpanded: true,
isFocused: false,
onClick: fn(),
onFocus: fn(),
sectionIndex: 1,
sectionCount: 3,
roomCountInSection: 5,
indexInList: 3,
},
decorators: [
(Story) => (
<div role="treegrid" style={{ width: "320px" }}>
<Story />
</div>
),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=10657-20703&p=f",
},
},
} satisfies Meta<typeof RoomListSectionHeaderViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Collapsed: Story = {
args: {
isExpanded: false,
},
};
export const LongTitle: Story = {
args: {
title: "This is a very long title that should be truncated with an ellipsis",
},
};
export const FirstHeader: Story = {
args: {
sectionIndex: 0,
},
};
export const LastHeaderCollapsed: Story = {
args: {
isExpanded: false,
sectionIndex: 2,
},
};
@@ -0,0 +1,32 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListSectionHeaderView.stories";
const { Default } = composeStories(stories);
describe("<RoomListSectionHeaderView /> stories", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("should call onClick when the header is clicked", async () => {
const user = userEvent.setup();
const { getByRole } = render(<Default />);
const button = getByRole("gridcell", { name: "Toggle Favourites section" });
await user.click(button);
expect(Default.args.onClick).toHaveBeenCalled();
});
});
@@ -0,0 +1,121 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { memo, type JSX, type FocusEvent, type MouseEventHandler } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../viewmodel";
import styles from "./RoomListSectionHeaderView.module.css";
import { Flex } from "../../utils/Flex";
import { useI18n } from "../../utils/i18nContext";
import { getGroupHeaderAccessibleProps } from "../../utils/VirtualizedList";
/**
* The observable state snapshot for a room list section header.
*/
export interface RoomListSectionHeaderViewSnapshot {
/** Unique identifier for the section header (used for list keying) */
id: string;
/** The display title of the section header. */
title: string;
/** Whether the section is currently expanded. */
isExpanded: boolean;
}
/**
* Actions that can be performed on a room list section header.
*/
export interface RoomListSectionHeaderActions {
/** Handler invoked when the section header is clicked (toggles expand/collapse). */
onClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The view model type for the room list section header, combining its snapshot and actions.
*/
export type RoomListSectionHeaderViewModel = ViewModel<RoomListSectionHeaderViewSnapshot, RoomListSectionHeaderActions>;
/**
* Props for {@link RoomListSectionHeaderView}.
*/
export interface RoomListSectionHeaderViewProps {
/** The view model driving the section header's state and actions. */
vm: RoomListSectionHeaderViewModel;
/** Whether this header currently has focus within the roving tab index. */
isFocused: boolean;
/** Callback invoked when the header receives focus. */
onFocus: (headerId: string, e: FocusEvent) => void;
/** Index of this section in the list, sections and rooms included */
indexInList: number;
/** Index of this section in the list related to the others sections */
sectionIndex: number;
/** Total number of sections in the list */
sectionCount: number;
/** Number of rooms in this section */
roomCountInSection: number;
}
/**
* A collapsible section header in the room list.
*
* Renders a button that displays the section title alongside a chevron icon
* indicating the current expand/collapse state. Clicking the header toggles
* the section's expanded state via the view model.
*
* @example
* ```tsx
* <RoomListSectionHeaderView
* vm={sectionHeaderViewModel}
* isFocused={isHeaderFocused}
* onFocus={() => setFocusedHeader(sectionId)}
* sectionIndex={index}
* sectionCount={totalSections}
* roomCountInSection={roomCount}
* />
* ```
*/
export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView({
vm,
isFocused,
onFocus,
indexInList,
sectionIndex,
sectionCount,
roomCountInSection,
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { id, title, isExpanded } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
return (
<div
aria-expanded={isExpanded}
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
>
<button
type="button"
role="gridcell"
className={classNames(styles.header, {
[styles.firstHeader]: sectionIndex === 0,
// If the section is collapsed and it's the last one
[styles.lastHeader]: !isExpanded && isLastSection,
})}
onClick={vm.onClick}
aria-expanded={isExpanded}
onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1}
aria-label={_t("room_list|section_header|toggle", { section: title })}
>
<Flex className={styles.container} align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon width="24px" height="24px" fill="var(--cpd-color-icon-secondary)" />
<span className={styles.title}>{title}</span>
</Flex>
</button>
</div>
);
});
@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListSectionHeaderView /> stories > renders Default story 1`] = `
<div>
<div
role="treegrid"
style="width: 320px;"
>
<div
aria-expanded="true"
aria-level="1"
aria-posinset="2"
aria-rowindex="4"
aria-setsize="5"
role="row"
>
<button
aria-expanded="true"
aria-label="Toggle Favourites section"
class="header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="flex container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="title"
>
Favourites
</span>
</div>
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
export type {
RoomListSectionHeaderViewModel,
RoomListSectionHeaderViewSnapshot,
RoomListSectionHeaderActions,
} from "./RoomListSectionHeaderView";
@@ -18,8 +18,11 @@ import {
renderAvatar,
createGetRoomItemViewModel,
mockRoomIds,
smallListRoomIds,
largeListRoomIds,
mockSections,
createGetSectionHeaderViewModel,
mockSmallListSections,
mockLargeListSections,
mockLargeListRoomIds,
} from "../story-mocks";
type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
@@ -32,6 +35,7 @@ const RoomListViewWrapperImpl = ({
createChatRoom,
createRoom,
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
@@ -41,6 +45,7 @@ const RoomListViewWrapperImpl = ({
createChatRoom,
createRoom,
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
@@ -81,15 +86,17 @@ const meta = {
spaceId: "!space:server",
filterKeys: undefined,
},
roomIds: mockRoomIds,
sections: mockSections,
canCreateRoom: true,
// Action properties (callbacks)
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(mockRoomIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockSections.map((section) => section.id)),
updateVisibleRooms: fn(),
renderAvatar,
isFlatList: true,
},
parameters: {
design: {
@@ -104,6 +111,12 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Section: Story = {
args: {
isFlatList: false,
},
};
export const Loading: Story = {
args: {
isLoadingRooms: true,
@@ -148,7 +161,6 @@ export const WithSelection: Story = {
export const EmptyFavouriteFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["favourite", "people"],
activeFilterId: "favourite",
},
@@ -157,7 +169,6 @@ export const EmptyFavouriteFilter: Story = {
export const EmptyPeopleFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["people", "rooms"],
activeFilterId: "people",
},
@@ -166,7 +177,6 @@ export const EmptyPeopleFilter: Story = {
export const EmptyRoomsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["rooms", "people"],
activeFilterId: "rooms",
},
@@ -175,7 +185,6 @@ export const EmptyRoomsFilter: Story = {
export const EmptyUnreadFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["unread", "people"],
activeFilterId: "unread",
},
@@ -184,7 +193,6 @@ export const EmptyUnreadFilter: Story = {
export const EmptyInvitesFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["invites", "people"],
activeFilterId: "invites",
},
@@ -193,7 +201,7 @@ export const EmptyInvitesFilter: Story = {
export const EmptyMentionsFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["mentions", "people"],
activeFilterId: "mentions",
},
@@ -202,22 +210,37 @@ export const EmptyMentionsFilter: Story = {
export const EmptyLowPriorityFilter: Story = {
args: {
isRoomListEmpty: true,
roomIds: [],
filterIds: ["low_priority", "people"],
activeFilterId: "low_priority",
},
};
export const SmallList: Story = {
export const SmallFlatList: Story = {
args: {
roomIds: smallListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(smallListRoomIds),
sections: mockSmallListSections,
},
};
export const LargeList: Story = {
export const LargeFlatList: Story = {
args: {
roomIds: largeListRoomIds,
getRoomItemViewModel: createGetRoomItemViewModel(largeListRoomIds),
sections: mockLargeListSections,
getRoomItemViewModel: createGetRoomItemViewModel(mockLargeListRoomIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockLargeListSections.map((section) => section.id)),
},
};
export const SmallSectionList: Story = {
args: {
isFlatList: false,
sections: mockSmallListSections,
},
};
export const LargeSectionList: Story = {
args: {
isFlatList: false,
sections: mockLargeListSections,
getRoomItemViewModel: createGetRoomItemViewModel(mockLargeListRoomIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mockLargeListSections.map((section) => section.id)),
},
};
@@ -20,8 +20,10 @@ const {
Empty,
EmptyWithoutCreatePermission,
WithActiveFilter,
SmallList,
LargeList,
SmallFlatList,
LargeFlatList,
SmallSectionList,
LargeSectionList,
EmptyFavouriteFilter,
EmptyPeopleFilter,
EmptyRoomsFilter,
@@ -67,13 +69,23 @@ describe("<RoomListView />", () => {
expect(container).toMatchSnapshot();
});
it("renders SmallList story", () => {
const { container } = renderWithMockContext(<SmallList />);
it("renders SmallFlatList story", () => {
const { container } = renderWithMockContext(<SmallFlatList />);
expect(container).toMatchSnapshot();
});
it("renders LargeList story", () => {
const { container } = renderWithMockContext(<LargeList />);
it("renders LargeFlatList story", () => {
const { container } = renderWithMockContext(<LargeFlatList />);
expect(container).toMatchSnapshot();
});
it("renders SmallSectionList story", () => {
const { container } = renderWithMockContext(<SmallSectionList />);
expect(container).toMatchSnapshot();
});
it("renders LargeSectionList story", () => {
const { container } = renderWithMockContext(<LargeSectionList />);
expect(container).toMatchSnapshot();
});
@@ -13,6 +13,14 @@ import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyStateView } from "./RoomListEmptyStateView";
import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView";
import { type Room, type RoomItemViewModel } from "../RoomListItemView";
import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView";
export type RoomListSection = {
/** Unique identifier for the section */
id: string;
/** Array of room IDs that belong to this section */
roomIds: string[];
};
/**
* Snapshot for the room list view
@@ -28,14 +36,16 @@ export type RoomListSnapshot = {
activeFilterId?: FilterId;
/** Room list state */
roomListState: RoomListViewState;
/** Array of room IDs for virtualization */
roomIds: string[];
/** Array of sections in the room list */
sections: RoomListSection[];
/** Optional description for the empty state */
emptyStateDescription?: string;
/** Optional action element for the empty state */
emptyStateAction?: ReactNode;
/** Whether the user can create rooms */
canCreateRoom?: boolean;
/** Whether the room list is displayed as a flat list */
isFlatList: boolean;
};
/**
@@ -52,6 +62,8 @@ export interface RoomListViewActions {
getRoomItemViewModel: (roomId: string) => RoomItemViewModel;
/** Called when the visible range changes (virtualization API) */
updateVisibleRooms: (startIndex: number, endIndex: number) => void;
/** Get view model for a specific section header (virtualization API) */
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
}
/**
@@ -6,7 +6,13 @@
*/
export { RoomListView } from "./RoomListView";
export type { RoomListViewProps, RoomListViewModel, RoomListSnapshot, RoomListViewActions } from "./RoomListView";
export type {
RoomListViewProps,
RoomListViewModel,
RoomListSnapshot,
RoomListViewActions,
RoomListSection,
} from "./RoomListView";
export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
export { RoomListEmptyStateView } from "./RoomListEmptyStateView";
export type { RoomListEmptyStateViewProps } from "./RoomListEmptyStateView";
@@ -15,19 +15,23 @@ import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView";
import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import type { FilterId } from "../RoomListPrimaryFilters";
import { renderAvatar, createGetRoomItemViewModel, mockRoomIds } from "../story-mocks";
import {
renderAvatar,
createGetRoomItemViewModel,
mock10RoomsIds,
createGetSectionHeaderViewModel,
mock10RoomsSections,
} from "../story-mocks";
type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
// Use first 10 room IDs for this story
const storyRoomIds = mockRoomIds.slice(0, 10);
// Wrapper component that creates a mocked ViewModel
const RoomListWrapperImpl = ({
onToggleFilter,
createChatRoom,
createRoom,
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
renderAvatar: renderAvatarProp,
...rest
@@ -37,6 +41,7 @@ const RoomListWrapperImpl = ({
createChatRoom,
createRoom,
getRoomItemViewModel,
getSectionHeaderViewModel,
updateVisibleRooms,
});
@@ -65,15 +70,17 @@ const meta = {
isRoomListEmpty: false,
filterIds: mockFilterIds,
activeFilterId: undefined,
roomIds: storyRoomIds,
sections: mock10RoomsSections,
roomListState: defaultRoomListState,
canCreateRoom: true,
onToggleFilter: fn(),
createChatRoom: fn(),
createRoom: fn(),
getRoomItemViewModel: createGetRoomItemViewModel(storyRoomIds),
getRoomItemViewModel: createGetRoomItemViewModel(mock10RoomsIds),
getSectionHeaderViewModel: createGetSectionHeaderViewModel(mock10RoomsSections.map((section) => section.id)),
updateVisibleRooms: fn(),
renderAvatar,
isFlatList: true,
},
parameters: {
design: {
@@ -94,3 +101,9 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Sections: Story = {
args: {
isFlatList: false,
},
};
@@ -9,11 +9,18 @@ import React, { useCallback, useMemo, useRef, type JSX, type ReactNode } from "r
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { RoomListItemView, type Room } from "../RoomListItemView";
import { type Room } from "../RoomListItemView";
import { useViewModel } from "../../viewmodel";
import { _t } from "../../utils/i18n";
import { FlatVirtualizedList, type VirtualizedListContext } from "../../utils/VirtualizedList";
import type { RoomListViewModel } from "../RoomListView";
import {
FlatVirtualizedList,
getContainerAccessibleProps,
type VirtualizedListContext,
} from "../../utils/VirtualizedList";
import type { RoomListSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList } from "../../utils/VirtualizedList";
import { RoomListSectionHeaderView } from "../RoomListSectionHeaderView";
import { RoomListItemAccessibilityWrapper } from "../RoomListItemAccessibilityWrapper";
/**
* Filter key type - opaque string type for filter identifiers
@@ -59,7 +66,24 @@ const ROOM_LIST_ITEM_HEIGHT = 52;
/**
* Type for context used in ListView
*/
type Context = { spaceId: string; filterKeys: FilterKey[] | undefined };
type Context = {
/** Space ID for context tracking */
spaceId: string;
/** Active filter keys for context tracking */
filterKeys: FilterKey[] | undefined;
/** Active room index for keyboard navigation */
activeRoomIndex: number | undefined;
/** Sections of the room list */
sections: RoomListSnapshot["sections"];
/** Total number of rooms in the list */
roomCount: number;
/** Number of sections in the list */
sectionCount: number;
/** Room list view model */
vm: RoomListViewModel;
/** List is in flat or section mode */
isFlatList: boolean;
};
/**
* Amount to extend the top and bottom of the viewport by.
@@ -83,11 +107,23 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
*/
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
const snapshot = useViewModel(vm);
const { roomListState, roomIds } = snapshot;
const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomIds = useMemo(() => sections.flatMap((section) => section.roomIds), [sections]);
const roomCount = roomIds.length;
const sectionCount = sections.length;
const totalCount = roomCount + sectionCount;
const groups = useMemo(
() =>
sections.map((section) => ({
header: section.id,
items: section.roomIds,
})),
[sections],
);
/**
* Callback when the visible range changes
@@ -110,7 +146,9 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
roomIndexInSection: number,
): JSX.Element => {
const { activeRoomIndex, roomCount, vm, isFlatList } = context.context;
const isSelected = activeRoomIndex === index;
const roomItemVM = vm.getRoomItemViewModel(roomId);
@@ -118,8 +156,11 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === roomId;
const isFirstItem = index === 0;
const isLastItem = index === roomCount - 1;
return (
<RoomListItemView
<RoomListItemAccessibilityWrapper
key={roomId}
vm={roomItemVM}
renderAvatar={renderAvatar}
@@ -127,24 +168,125 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
isFocused={isFocused}
onFocus={onFocus}
roomIndex={index}
roomIndexInSection={roomIndexInSection}
roomCount={roomCount}
isFirstItem={isFirstItem}
isLastItem={isLastItem}
isInFlatList={isFlatList}
/>
);
},
[activeRoomIndex, roomCount, renderAvatar, vm],
[renderAvatar],
);
/**
* Get the item component for a specific index in a grouped list
* Since we have sections, we can calculate the room's index within its section and pass it to getItemComponent
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponentForGroupedList = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
groupIndex: number,
): JSX.Element => {
const { sections } = context.context;
const roomIndexInSection = sections[groupIndex].roomIds.findIndex((id) => id === roomId);
return getItemComponent(index, roomId, context, onFocus, roomIndexInSection);
},
[getItemComponent],
);
/**
* Get the item component for a specific index in a flat list
* Since we don't have sections, we can pass 0 for the room's index within its section to getItemComponent
* Gets the room's view model and passes it to RoomListItemView
*/
const getItemComponentForFlatList = useCallback(
(
index: number,
roomId: string,
context: VirtualizedListContext<Context>,
onFocus: (item: string, e: React.FocusEvent) => void,
): JSX.Element => {
// For a flat list, we don't have sections, so roomIndexInSection is unused and can be set to 0
return getItemComponent(index, roomId, context, onFocus, 0);
},
[getItemComponent],
);
/**
* Get the group header component for a specific group
*/
const getGroupHeaderComponent = useCallback(
(
groupIndex: number,
headerId: string,
context: VirtualizedListContext<Context>,
onFocus: (header: string, e: React.FocusEvent) => void,
): JSX.Element => {
const { vm, sectionCount, sections } = context.context;
const sectionHeaderVM = vm.getSectionHeaderViewModel(headerId);
const indexInList = sections
.slice(0, groupIndex)
// +1 for each section header
.reduce((acc, section) => acc + section.roomIds.length + 1, 0);
const roomCountInSection = sections[groupIndex].roomIds.length;
// Item is focused when the list has focus AND this item's key matches tabIndexKey
// This matches the old RoomList implementation's roving tabindex pattern
const isFocused = context.focused && context.tabIndexKey === headerId;
return (
<RoomListSectionHeaderView
vm={sectionHeaderVM}
isFocused={isFocused}
onFocus={onFocus}
indexInList={indexInList}
sectionIndex={groupIndex}
sectionCount={sectionCount}
roomCountInSection={roomCountInSection}
/>
);
},
[],
);
/**
* Get the key for a room item
* Since we're using virtualization, items are always room ID strings
*/
const getItemKey = useCallback((item: string): string => {
return item;
}, []);
const getItemKey = useCallback((item: string): string => item, []);
/**
* Get the key for a group header
* We are passing the section ID as the header key, which is a string, so we can return it directly
*/
const getHeaderKey = useCallback((header: string): string => header, []);
const context = useMemo(
() => ({ spaceId: roomListState.spaceId || "", filterKeys: roomListState.filterKeys }),
[roomListState.spaceId, roomListState.filterKeys],
() => ({
spaceId: roomListState.spaceId || "",
filterKeys: roomListState.filterKeys,
sections,
activeRoomIndex,
roomCount,
sectionCount,
vm,
isFlatList,
}),
[
roomListState.spaceId,
roomListState.filterKeys,
sections,
activeRoomIndex,
roomCount,
sectionCount,
vm,
isFlatList,
],
);
/**
@@ -173,26 +315,51 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[activeRoomIndex],
);
const isItemFocusable = useCallback(() => true, []);
const isGroupHeaderFocusable = useCallback(() => true, []);
const increaseViewportBy = useMemo(
() => ({
top: EXTENDED_VIEWPORT_HEIGHT,
bottom: EXTENDED_VIEWPORT_HEIGHT,
}),
[],
);
const commonProps = {
context,
scrollIntoViewOnChange,
// If fixedItemHeight is not set and initialTopMostItemIndex=undefined, virtuoso crashes
// If we don't set it, it works
...(activeRoomIndex !== undefined ? { initialTopMostItemIndex: activeRoomIndex } : {}),
["data-testid"]: "room-list",
["aria-label"]: _t("room_list|list_title"),
getItemKey,
isItemFocusable,
rangeChanged,
onKeyDown,
increaseViewportBy,
};
if (isFlatList) {
return (
<FlatVirtualizedList
{...commonProps}
{...getContainerAccessibleProps("listbox")}
items={roomIds}
getItemComponent={getItemComponentForFlatList}
/>
);
}
return (
<FlatVirtualizedList
context={context}
scrollIntoViewOnChange={scrollIntoViewOnChange}
// If fixedItemHeight is not set and initialTopMostItemIndex=undefined, virtuoso crashes
// Id we don't set it, it works
{...(activeRoomIndex !== undefined ? { initialTopMostItemIndex: activeRoomIndex } : {})}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
items={roomIds}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
rangeChanged={rangeChanged}
onKeyDown={onKeyDown}
increaseViewportBy={{
bottom: EXTENDED_VIEWPORT_HEIGHT,
top: EXTENDED_VIEWPORT_HEIGHT,
}}
<GroupedVirtualizedList<string, string, Context>
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}
getItemComponent={getItemComponentForGroupedList}
isGroupHeaderFocusable={isGroupHeaderFocusable}
/>
);
}
@@ -9,6 +9,8 @@ import React from "react";
import { fn } from "storybook/test";
import { type Room, type RoomItemViewModel, type RoomListItemSnapshot, RoomNotifState } from "./RoomListItemView";
import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
import { MockViewModel } from "../viewmodel";
/**
* Mock avatar component for stories
@@ -100,6 +102,23 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number):
roomNotifState: RoomNotifState.AllMessages,
});
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomItemViewModel {
const snapshot = createMockRoomSnapshot(roomId, name, index);
return {
getSnapshot: () => snapshot,
subscribe: fn(),
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
};
}
/**
* Create a mock getRoomItemViewModel function for stories
*/
@@ -107,31 +126,60 @@ export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string)
const viewModels = new Map<string, RoomItemViewModel>();
roomIds.forEach((roomId, index) => {
const name = roomNames[index % roomNames.length];
const snapshot = createMockRoomSnapshot(roomId, name, index);
const mockViewModel = {
getSnapshot: () => snapshot,
subscribe: fn(),
unsubscribe: fn(),
onOpenRoom: fn(),
onMarkAsRead: fn(),
onMarkAsUnread: fn(),
onToggleFavorite: fn(),
onToggleLowPriority: fn(),
onInvite: fn(),
onCopyRoomLink: fn(),
onLeaveRoom: fn(),
onSetRoomNotifState: fn(),
};
viewModels.set(roomId, mockViewModel);
viewModels.set(roomId, createMockRoomItemViewModel(roomId, name, index));
});
return (roomId: string) => viewModels.get(roomId)!;
};
export const createGetSectionHeaderViewModel = (
sectionIds: string[],
): ((sectionId: string) => RoomListSectionHeaderViewModel) => {
const viewModels = new Map<string, RoomListSectionHeaderViewModel>();
sectionIds.forEach((sectionId) => {
const snapshot = {
id: sectionId,
title: sectionId[0].toUpperCase() + sectionId.slice(1),
isExpanded: true,
};
const vm = new MockViewModel(snapshot) as unknown as RoomListSectionHeaderViewModel;
Object.assign(vm, {
onClick: fn(),
onFocus: fn(),
});
viewModels.set(sectionId, vm);
});
return (sectionId: string) => viewModels.get(sectionId)!;
};
/**
* Mock room IDs for different list sizes
*/
export const mock10RoomsIds = Array.from({ length: 10 }, (_, i) => `!room${i}:server`);
export const mock10RoomsSections = [
{ id: "favourites", roomIds: mock10RoomsIds.slice(0, 3) },
{ id: "chats", roomIds: mock10RoomsIds.slice(3, 4) },
{ id: "low-priority", roomIds: mock10RoomsIds.slice(4) },
];
export const mockRoomIds = Array.from({ length: 20 }, (_, i) => `!room${i}:server`);
export const smallListRoomIds = mockRoomIds.slice(0, 5);
export const largeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);
export const mockSections = [
{ id: "favourites", roomIds: mockRoomIds.slice(0, 5) },
{ id: "chats", roomIds: mockRoomIds.slice(5, 15) },
{ id: "low-priority", roomIds: mockRoomIds.slice(15) },
];
export const mockSmallListRoomIds = mockRoomIds.slice(0, 5);
export const mockSmallListSections = [
{ id: "favourites", roomIds: mockSmallListRoomIds.slice(0, 2) },
{ id: "chats", roomIds: mockSmallListRoomIds.slice(2, 0) },
];
export const mockLargeListRoomIds = Array.from({ length: 100 }, (_, i) => `!room${i}:server`);
export const mockLargeListSections = [
{ id: "favourites", roomIds: mockLargeListRoomIds.slice(0, 23) },
{ id: "chats", roomIds: mockLargeListRoomIds.slice(23, 52) },
{ id: "low-priority", roomIds: mockLargeListRoomIds.slice(52) },
];
+1 -5
View File
@@ -153,11 +153,7 @@ export default defineConfig({
],
},
optimizeDeps: {
include: [
"vite-plugin-node-polyfills/shims/buffer",
"vite-plugin-node-polyfills/shims/process",
"react-virtuoso",
],
include: ["vite-plugin-node-polyfills/shims/buffer", "vite-plugin-node-polyfills/shims/process"],
},
resolve: {
alias: {