Room list: drag and drop rooms into sections (#33366)

* chore: add dnd kit deps

* chore: patch dnd kit to fix ts error

* feat(sc): add drag-and-drop to room list item and wrapper

* feat(sc): make the room list header a droppable element

* feat(sc): add dnd to room list view

* feat(tags): can tag room as CHAT

* feat(vm): implement `changeRoomSection`

* feat(sc): disable dragging in flat list

* fix: disable keyboard navigation when dragging element

* test(sc): update snapshots

* test(sc): add dnd test

* test(e2e): add e2e tests for room drag and drop

* test(vm): add tests for changeRoomSection

* fix: remove focus visible when dropping with the mouse

* test(playwright): update existing screenshots

* chore(sc): move numbers out of main build

The Ew RecorderWorklet imports shared component bundle. However if the
bundle uses some deps using document/window which, the worklet will not
work.

The solution is to put the used functions into a separate bundle.

* doc(sc): add subpath import into README

* doc: typo barrel/bundle

* test: improve test expect

* refactor: add utils to section tag

* fix: incorrect check in tagRoom

* fix: add doc about dndkit tunning
This commit is contained in:
Florian Duros
2026-05-13 09:06:22 +00:00
committed by GitHub
parent 97da3be67a
commit 85aca65a81
50 changed files with 4845 additions and 3871 deletions
@@ -8,7 +8,7 @@
import { type Page } from "@playwright/test";
import { expect, test } from "../../../element-web-test";
import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
test.describe("Room list custom sections", () => {
test.use({
@@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => {
await expect(dialog).not.toBeVisible();
}
/**
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
* Verifies that the closest preceding aria-level=1 row is the expected section header.
*/
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
const roomList = getRoomList(page);
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
// Room row must be at aria-level=2 (i.e. inside a section)
await expect(roomRow).toHaveAttribute("aria-level", "2");
// The closest preceding aria-level=1 row must be the expected section header.
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
await expect(closestSectionHeader).toContainText(sectionName);
}
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
@@ -6,7 +6,7 @@
*/
import { expect, test } from "../../../element-web-test";
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
test.describe("Room list sections", () => {
test.use({
@@ -182,6 +182,37 @@ test.describe("Room list sections", () => {
roomItem = roomList.getByRole("row", { name: "Open room my room" });
await expect(roomItem).toBeVisible();
});
test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
await dragRoomToSection(page, "my room", "Favourites");
await assertRoomInSection(page, "Favourites", "my room");
});
test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => {
const favouriteId = await app.client.createRoom({ name: "my room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId);
// Create a second favourite room to ensure we stay in section mode (not flat list)
const favouriteId2 = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, roomId) => {
await client.setRoomTag(roomId, "m.favourite");
}, favouriteId2);
// Ensure the Chats section is visible by creating a room in it
await app.client.createRoom({ name: "room in chats" });
await dragRoomToSection(page, "my room", "Chats");
await assertRoomInSection(page, "Chats", "my room");
});
});
test("should show unread indicator on section header", async ({ page, app, bot }) => {
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type Locator, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Get the room list
@@ -35,6 +35,49 @@ export function getSectionHeader(page: Page, sectionName: string, isUnread = fal
});
}
/**
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
* Verifies that the closest preceding aria-level=1 row is the expected section header.
*/
export async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
const roomList = getRoomList(page);
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
// Room row must be at aria-level=2 (i.e. inside a section)
await expect(roomRow).toHaveAttribute("aria-level", "2");
// The closest preceding aria-level=1 row must be the expected section header.
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
await expect(closestSectionHeader).toContainText(sectionName);
}
/**
* Drag and drop a room row onto a section header
* @param page
* @param roomName
* @param sectionName
*/
export async function dragRoomToSection(page: Page, roomName: string, sectionName: string): Promise<void> {
const sourceRow = getRoomList(page).getByRole("row", { name: `Open room ${roomName}` });
const source = sourceRow.locator("button").first();
const target = getSectionHeader(page, sectionName);
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetY = targetBox.y + targetBox.height / 2;
// Grab the room
await page.mouse.move(sourceX, sourceY);
await page.mouse.down();
// Move the room on the section header
await page.mouse.move(sourceX, targetY, { steps: 10 });
// Drop the room
await page.mouse.up();
}
/**
* Get the primary filters container
* @param page