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
@@ -117,6 +117,23 @@ describe("RoomListSectionHeaderViewModel", () => {
});
});
describe("canBeReordered", () => {
it.each([
[DefaultTagID.Favourite, false],
[DefaultTagID.LowPriority, false],
[CHATS_TAG, true],
["element.io.section.custom", true],
])("should be %s for tag %s", (tag, expected) => {
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
expect(vm.getSnapshot().canBeReordered).toBe(expected);
});
});
describe("onCustomSectionDataChange", () => {
let watchCallback: () => void;
@@ -1178,6 +1178,108 @@ describe("RoomListViewModel", () => {
expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
});
describe("Drag and drop", () => {
beforeEach(() => {
viewModel = new RoomListViewModel({ client: matrixClient });
// Ensure section header VMs are created before tests that interact with them
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
viewModel.getSectionHeaderViewModel(CHATS_TAG);
viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority);
});
it("should delegate changeSectionOrder to RoomListStoreV3.reorderSection", async () => {
const reorderSpy = jest
.spyOn(RoomListStoreV3.instance, "reorderSection")
.mockResolvedValue(undefined);
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
expect(reorderSpy).toHaveBeenCalledWith(DefaultTagID.Favourite, CHATS_TAG);
});
it("should scroll the moved section back into view after reordering", async () => {
jest.spyOn(RoomListStoreV3.instance, "reorderSection").mockResolvedValue(undefined);
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
expect(viewModel.getSnapshot().roomListState.scrollToSectionTag).toBe(DefaultTagID.Favourite);
});
it("should collapse every section on drag start", () => {
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
viewModel.onSectionDragStart();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(false);
for (const section of viewModel.getSnapshot().sections) {
expect(section.roomIds).toEqual([]);
}
});
it("should restore the pre-drag expansion state on drag end", () => {
// Collapse Favourite before the drag; other sections remain expanded
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([]);
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
"!reg1:server",
"!reg2:server",
]);
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
"!low1:server",
]);
});
it("should re-snapshot expansion state on each drag start", () => {
// First cycle: Favourite is collapsed before the drag
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
// Between cycles: collapse CHATS_TAG as well
viewModel.getSectionHeaderViewModel(CHATS_TAG).onClick();
viewModel.onSectionDragStart();
viewModel.onSectionDragEnd();
// The second drag end must restore the state captured at the second drag start
// (Favourite collapsed, CHATS_TAG collapsed, LowPriority expanded), not the first cycle's snapshot.
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
});
it("should be a no-op when drag end is called without drag start", () => {
viewModel.onSectionDragEnd();
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
const snapshot = viewModel.getSnapshot();
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([
"!fav1:server",
"!fav2:server",
]);
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
"!reg1:server",
"!reg2:server",
]);
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
"!low1:server",
]);
});
});
});
});