+ );
+}
+
+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 => (
+
+ ),
+ ],
+};
+
+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 && (
+
+ )}
+ {viewModel.showExplore && (
+
+ )}
+
+ );
+};
diff --git a/packages/shared-components/src/room-list/RoomListSearch/index.tsx b/packages/shared-components/src/room-list/RoomListSearch/index.tsx
new file mode 100644
index 0000000000..88c2096ca3
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListSearch/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 { RoomListSearch } from "./RoomListSearch";
+export type { RoomListSearchProps, RoomListSearchViewModel } from "./RoomListSearch";
diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListEmptyState.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyState.tsx
new file mode 100644
index 0000000000..d4aaa6c78b
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListView/RoomListEmptyState.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX, type ReactNode } from "react";
+
+import styles from "./RoomListView.module.css";
+
+/**
+ * Props for RoomListEmptyState component
+ */
+export interface RoomListEmptyStateProps {
+ /** The title to display in the empty state */
+ title: string;
+ /** The description text to display */
+ description?: string;
+ /** Optional action element (e.g., a button) to display */
+ action?: ReactNode;
+}
+
+/**
+ * Empty state component for the room list.
+ * Displays a message when no rooms are available.
+ */
+export const RoomListEmptyState: React.FC = ({ title, description, action }): JSX.Element => {
+ return (
+
+
{title}
+ {description &&
{description}
}
+ {action}
+
+ );
+};
diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx
new file mode 100644
index 0000000000..e5c2aff5de
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListView/RoomListLoadingSkeleton.tsx
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX } from "react";
+
+import styles from "./RoomListView.module.css";
+
+/**
+ * Loading skeleton component for the room list.
+ * Displays a simple loading indicator while rooms are being fetched.
+ */
+export const RoomListLoadingSkeleton: React.FC = (): JSX.Element => {
+ return ;
+};
diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css
new file mode 100644
index 0000000000..9e77b8023a
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+.skeleton {
+ position: relative;
+ margin-left: 4px;
+ height: 100%;
+ flex: 1;
+}
+
+/* Skeleton animation - note: mask-image requires SVG from element-web */
+.skeleton::before {
+ background-color: var(--cpd-color-bg-subtle-secondary);
+ width: 100%;
+ height: 100%;
+ content: "";
+ position: absolute;
+ mask-repeat: repeat-y;
+ mask-size: auto 96px;
+}
+
+/* Element-web provides the actual mask-image */
+:global(.mx_RoomListSkeleton)::before {
+ mask-image: url("$(res)/img/element-icons/roomlist/room-list-item-skeleton.svg");
+}
diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx
new file mode 100644
index 0000000000..0e8b7db89d
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { type JSX, type ReactNode } from "react";
+
+import { RoomListPrimaryFilters, type RoomListPrimaryFiltersViewModel } from "../RoomListPrimaryFilters";
+import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
+import { RoomListEmptyState } from "./RoomListEmptyState";
+import { RoomList, type RoomListViewModel } from "../RoomList";
+import { type RoomListItemViewModel } from "../RoomListItem";
+
+/**
+ * ViewModel interface for RoomListView
+ */
+export interface RoomListViewViewModel {
+ /** Whether the rooms are currently loading */
+ isLoadingRooms: boolean;
+ /** Whether the room list is empty */
+ isRoomListEmpty: boolean;
+ /** View model for the primary filters */
+ filtersViewModel: RoomListPrimaryFiltersViewModel;
+ /** View model for the room list */
+ roomListViewModel: RoomListViewModel;
+ /** Title for the empty state */
+ emptyStateTitle: string;
+ /** Optional description for the empty state */
+ emptyStateDescription?: string;
+ /** Optional action element for the empty state */
+ emptyStateAction?: ReactNode;
+}
+
+/**
+ * Props for RoomListView component
+ */
+export interface RoomListViewProps {
+ /** The view model containing list data */
+ viewModel: RoomListViewViewModel;
+ /** Render function for room avatar */
+ renderAvatar: (roomViewModel: RoomListItemViewModel) => ReactNode;
+}
+
+/**
+ * The main room list view component.
+ * Manages the display of filters, loading states, empty states, and the room list.
+ */
+export const RoomListView: React.FC = ({ viewModel, renderAvatar }): JSX.Element => {
+ let listBody: ReactNode;
+
+ if (viewModel.isLoadingRooms) {
+ listBody = ;
+ } else if (viewModel.isRoomListEmpty) {
+ listBody = (
+
+ );
+ } else {
+ listBody = ;
+ }
+
+ return (
+ <>
+
+ {listBody}
+ >
+ );
+};
diff --git a/packages/shared-components/src/room-list/RoomListView/index.tsx b/packages/shared-components/src/room-list/RoomListView/index.tsx
new file mode 100644
index 0000000000..17fc64fa59
--- /dev/null
+++ b/packages/shared-components/src/room-list/RoomListView/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 { RoomListView } from "./RoomListView";
+export type { RoomListViewProps, RoomListViewViewModel } from "./RoomListView";
+export { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
+export { RoomListEmptyState } from "./RoomListEmptyState";
+export type { RoomListEmptyStateProps } from "./RoomListEmptyState";
diff --git a/packages/shared-components/src/test/setupTests.ts b/packages/shared-components/src/test/setupTests.ts
index 43ffc0c071..26e2c4af53 100644
--- a/packages/shared-components/src/test/setupTests.ts
+++ b/packages/shared-components/src/test/setupTests.ts
@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
+import "@testing-library/jest-dom";
import fetchMock from "fetch-mock-jest";
import { setLanguage } from "../../src/utils/i18n";
diff --git a/packages/shared-components/src/utils/ListView/ListView.tsx b/packages/shared-components/src/utils/ListView/ListView.tsx
new file mode 100644
index 0000000000..eec7efe662
--- /dev/null
+++ b/packages/shared-components/src/utils/ListView/ListView.tsx
@@ -0,0 +1,335 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
+import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
+
+/**
+ * Keyboard key codes
+ */
+export const Key = {
+ ARROW_UP: "ArrowUp",
+ ARROW_DOWN: "ArrowDown",
+ HOME: "Home",
+ END: "End",
+ PAGE_UP: "PageUp",
+ PAGE_DOWN: "PageDown",
+ ENTER: "Enter",
+ SPACE: "Space",
+} as const;
+
+/**
+ * Check if a keyboard event includes modifier keys
+ */
+export function isModifiedKeyEvent(event: React.KeyboardEvent): boolean {
+ return event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
+}
+
+/**
+ * Context object passed to each list item containing the currently focused key
+ * and any additional context data from the parent component.
+ */
+export type ListContext = {
+ /** The key of item that should have tabIndex == 0 */
+ tabIndexKey?: string;
+ /** Whether an item in the list is currently focused */
+ focused: boolean;
+ /** Additional context data passed from the parent component */
+ context: Context;
+};
+
+export interface IListViewProps
+ extends Omit>, "data" | "itemContent" | "context"> {
+ /**
+ * The array of items to display in the virtualized list.
+ * Each item will be passed to getItemComponent for rendering.
+ */
+ items: Item[];
+
+ /**
+ * Function that renders each list item as a JSX element.
+ * @param index - The index of the item in the list
+ * @param item - The data item to render
+ * @param context - The context object containing the focused key and any additional data
+ * @param onFocus - A callback that is required to be called when the item component receives focus
+ * @returns JSX element representing the rendered item
+ */
+ getItemComponent: (
+ index: number,
+ item: Item,
+ context: ListContext,
+ onFocus: (item: Item, e: React.FocusEvent) => void,
+ ) => JSX.Element;
+
+ /**
+ * Optional additional context data to pass to each rendered item.
+ * This will be available in the ListContext passed to getItemComponent.
+ */
+ context?: Context;
+
+ /**
+ * Function to determine if an item can receive focus during keyboard navigation.
+ * @param item - The item to check for focusability
+ * @returns true if the item can be focused, false otherwise
+ */
+ isItemFocusable: (item: Item) => boolean;
+
+ /**
+ * Function to get the key to use for focusing an item.
+ * @param item - The item to get the key for
+ * @return The key to use for focusing the item
+ */
+ getItemKey: (item: Item) => string;
+
+ /**
+ * Callback function to handle key down events on the list container.
+ * ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
+ * and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
+ * @param e - The keyboard event
+ * @returns
+ */
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+}
+
+/**
+ * A generic virtualized list component built on top of react-virtuoso.
+ * Provides keyboard navigation and virtualized rendering for performance with large lists.
+ *
+ * @template Item - The type of data items in the list
+ * @template Context - The type of additional context data passed to items
+ */
+export function ListView(props: IListViewProps): React.ReactElement {
+ // Extract our custom props to avoid conflicts with Virtuoso props
+ const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
+ /** Reference to the Virtuoso component for programmatic scrolling */
+ const virtuosoHandleRef = useRef(null);
+ /** Reference to the DOM element containing the virtualized list */
+ const virtuosoDomRef = useRef(null);
+ /** Key of the item that should have tabIndex == 0 */
+ const [tabIndexKey, setTabIndexKey] = useState(
+ props.items[0] ? getItemKey(props.items[0]) : undefined,
+ );
+ /** Range of currently visible items in the viewport */
+ const [visibleRange, setVisibleRange] = useState(undefined);
+ /** Map from item keys to their indices in the items array */
+ const [keyToIndexMap, setKeyToIndexMap] = useState