Room list: add drag and drop of sections to reorder them (#33606)

* feat(rls): centralize room-list section ordering via getOrderedSections

Introduce a single source of truth for the ordered list of section
tags (defaults + custom) in section.ts. RoomListStoreV3 no longer
hard-codes the default tag order — it asks section.ts.

* feat(vm): add section reorder logic to RoomListStoreV3 and view model

* refactor(sc): extract RoomListSectionHeaderContent from RoomListSectionHeaderView

* feat(sc): add drag-and-drop reordering for room-list sections

* test(sc): update existing tests

* test: add new tests

* test(sc): add snapshot test for overlay

* test(e2e): add playright test for dnd of sections

* feat: tweak opacity

* fix: section detection when dragged

* feat: use relative position in section order

* chore: move style to room list section header view

* test: add e2e test for moving section before another

* feat: make favourite and low priority no draggable

* test: remove deprecated e2e test

* fix: type correctly dnd provider and hooks

* fix: use String instead of casting to string

* fix: wrong a11y attributes on section header

* test: fix keyboard navigation e2e tests

* feat: use custom a11y announcement for dnd

* fix: add aria-hidden to the overlay

* chore: remove duplicated code in a11Y announcement

* fix: increase keyboard drag offset

* fix: tests

* fix: virtuoso computed item key

* fix: aria-expanded double annoncement why dragging

* fix: re-add screen reader instructions

* chore: remove unused import

* fix: reduce keyboard drag offset

* fix: improve text readback on unread section

* feat: use a custom a11y plugin instead of buitin a11y plugin

* chore: formatting

* test: fix incorrect tests

* chore: use randomUUID

* fix: try to make test working

* fix: put back mock

* fix: circular import

* Revert "fix: circular import"

This reverts commit 6c69313ade9ed2ab17a4622c692b435fbdb94ad2.

* chore: add @dnd-kit/abstract to optimizeDeps.include

* test: fix e2e tests

* fix: disable interaction with section when dragging

* fix: be more explicit if the section will be dropped before or after

* fix: add info that space is dropping too

* fix: scroll to dropped section

* test: fix virtualized room list test

* chore: update lang

* chore: fix dead code analyze

* test: add provider section story

* fix: lang

* fix: again lang...

* fix: improve voice over on chrome

* test: upate snapshot

* fix: add readback when a section is over a non droppable element
This commit is contained in:
Florian Duros
2026-06-26 10:00:44 +00:00
committed by GitHub
parent 4e3f47b948
commit 1f83ba4bbb
40 changed files with 2064 additions and 342 deletions
@@ -9,7 +9,15 @@ import { type Page } from "@playwright/test";
import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common";
import { expect, test } from "../../../element-web-test";
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
import {
assertRoomInSection,
assertSectionsOrder,
dragRoomToSection,
dragSectionToSection,
getRoomList,
getRoomListHeader,
getSectionHeader,
} from "./utils";
test.describe("Room list custom sections", () => {
test.use({
@@ -287,6 +295,33 @@ test.describe("Room list custom sections", () => {
});
});
test.describe("Section reordering via dnd", () => {
test("should reorder custom sections via dnd", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
// Default placement: custom sections sit at the top of Chats
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
// Moves Work after Chats
await dragSectionToSection(page, "Work", "Chats");
await assertSectionsOrder(page, ["Personal", "Chats", "Work"]);
});
test("should insert a section before the target when dragging up", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
// Personal sits below Work, so dragging it onto Work inserts it before Work.
await dragSectionToSection(page, "Personal", "Work");
await assertSectionsOrder(page, ["Personal", "Work", "Chats"]);
});
});
test.describe("Adding a room to a custom section", () => {
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
@@ -30,8 +30,8 @@ export function getRoomListHeader(page: Page): Locator {
* @param isUnread Whether to look for the unread version of the section header
*/
export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
return getRoomList(page).getByRole("gridcell", {
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
return getRoomList(page).getByRole("button", {
name: isUnread ? `Toggle ${sectionName} section with unread rooms` : `Toggle ${sectionName} section`,
});
}
@@ -106,6 +106,60 @@ async function getBoundingBox(
return box;
}
/**
* Drag and drop a section header onto another section header. The dragged section is moved
* relative to the target: dropped before the target when dragging up, after the target when
* dragging down. Because the dnd start handler collapses every section, the layout changes
* once the drag activates — so the target position is recomputed after activation rather
* than cached up-front.
*/
export async function dragSectionToSection(
page: Page,
sourceSectionName: string,
targetSectionName: string,
): Promise<void> {
const source = getSectionHeader(page, sourceSectionName);
const sourceBox = await source.boundingBox();
if (!sourceBox) throw new Error(`Source section ${sourceSectionName} has no bounding box`);
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
// Grab the section header
await page.mouse.move(sourceX, sourceY);
await page.mouse.down();
// Move past the 5px PointerSensor activation threshold so the drag actually starts.
// This triggers onSectionDragStart, which collapses all sections.
await page.mouse.move(sourceX, sourceY + 10, { steps: 5 });
// Re-query the target now that the layout has reflowed.
const target = getSectionHeader(page, targetSectionName);
const targetBox = await target.boundingBox();
if (!targetBox) throw new Error(`Target section ${targetSectionName} has no bounding box`);
const targetY = targetBox.y + targetBox.height / 2;
// Move onto the (possibly relocated) target section header and drop.
await page.mouse.move(sourceX, targetY, { steps: 10 });
await page.mouse.up();
}
/**
* Assert the displayed section headers appear in the given top-to-bottom order.
*/
export async function assertSectionsOrder(page: Page, expectedOrder: string[]): Promise<void> {
const positions: Array<{ name: string; y: number }> = [];
for (const name of expectedOrder) {
const header = getSectionHeader(page, name);
await expect(header).toBeVisible();
const box = await header.boundingBox();
if (!box) throw new Error(`Section ${name} has no bounding box`);
positions.push({ name, y: box.y });
}
for (let i = 1; i < positions.length; i++) {
expect(positions[i].y).toBeGreaterThan(positions[i - 1].y);
}
}
/**
* Get the primary filters container
* @param page