Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/web/src/viewmodels/room-list/RoomListViewViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
});

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -428,7 +442,7 @@ export class RoomListViewViewModel
isRoomListEmpty,
activeFilterId,
roomListState,
roomIds,
sections,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
});

Expand All @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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",
]);
});
});

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/shared-components/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,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": {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,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";
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export RoomListItemAccessiblityWrapper too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 0c81d04

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/";
Expand Down
Original file line number Diff line number Diff line change
@@ -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">

Check warning on line 49 in packages/shared-components/src/room-list/RoomListItemAccessibilityWrapper/RoomListItemAccessibilityWrapper.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <select size=...>, <select multiple=...>, or <datalist> instead of the "listbox" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzYxwh3ztpHVdMFaeFr&open=AZzYxwh3ztpHVdMFaeFr&pullRequest=32735
<Story />
</div>
),
],
};

export const Sections: Story = {
args: {
isInFlatList: false,
},
decorators: [
(Story) => (
<div role="treegrid" aria-label="Room list" aria-rowcount={10}>
<Story />
</div>
),
],
};
Original file line number Diff line number Diff line change
@@ -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>;
});
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
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;
};

Expand All @@ -40,8 +41,8 @@
isSelected,
isFocused,
onFocus,
roomIndex,
roomCount,
isFirstItem,
isLastItem,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemProps): JSX.Element => {
Expand All @@ -62,9 +63,10 @@
isSelected={isSelected}
isFocused={isFocused}
onFocus={onFocus}
roomIndex={roomIndex}
roomCount={roomCount}
isFirstItem={isFirstItem}
isLastItem={isLastItem}
renderAvatar={renderAvatarProp}
role="option"
/>
);
};
Expand All @@ -76,28 +78,18 @@
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" }}>

Check warning on line 81 in packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <select size=...>, <select multiple=...>, or <datalist> instead of the "listbox" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZzYxwhlztpHVdMFaeFp&open=AZzYxwhlztpHVdMFaeFp&pullRequest=32735
<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,
},
Expand Down Expand Up @@ -263,14 +255,14 @@

export const FirstItem: Story = {
args: {
roomIndex: 0,
isFirstItem: true,
isSelected: true,
},
};

export const LastItem: Story = {
args: {
roomIndex: 9,
isLastItem: true,
isSelected: true,
},
};
Loading
Loading