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
@@ -19,6 +19,7 @@ import {
CHATS_TAG,
CUSTOM_SECTION_TAG_PREFIX,
isSectionTag,
reorderSection,
} from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
@@ -328,13 +329,129 @@ describe("section", () => {
await deleteSection(tag, false);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
expect(orderedCall![3]).toEqual([otherTag]);
// CHATS_TAG is appended because the stored order didn't include it (legacy default position).
expect(orderedCall![3]).toEqual([otherTag, CHATS_TAG]);
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
expect(customDataCall![3]).not.toHaveProperty(tag);
});
});
describe("reorderSection", () => {
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc`;
const customTag2 = `${CUSTOM_SECTION_TAG_PREFIX}def`;
function mockSettings(
orderedTags: string[],
customData: Record<string, { tag: string; name: string }> = {},
): void {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return orderedTags;
if (setting === "RoomList.CustomSectionData") return customData;
return null;
});
}
it.each<{
description: string;
initial: string[];
customData: Record<string, { tag: string; name: string }>;
source: string;
target: string;
expected: string[];
}>([
{
description: "a custom section after another custom section",
initial: [customTag, customTag2],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: customTag2,
expected: [customTag2, customTag, CHATS_TAG],
},
{
description: "a custom section before another when dragging up",
initial: [customTag2, customTag],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: customTag2,
expected: [customTag, customTag2, CHATS_TAG],
},
{
description: "a custom section past the Chats tag",
initial: [customTag, customTag2, CHATS_TAG],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: customTag,
target: CHATS_TAG,
expected: [customTag2, CHATS_TAG, customTag],
},
{
description: "the Chats tag above a custom section",
initial: [customTag, customTag2, CHATS_TAG],
customData: {
[customTag]: { tag: customTag, name: "A" },
[customTag2]: { tag: customTag2, name: "B" },
},
source: CHATS_TAG,
target: customTag,
expected: [CHATS_TAG, customTag, customTag2],
},
])(
"moves $description and saves the new order at ACCOUNT level",
async ({ initial, customData, source, target, expected }) => {
mockSettings(initial, customData);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await reorderSection(source, target);
expect(setValueSpy).toHaveBeenCalledWith(
"RoomList.OrderedCustomSections",
null,
expect.anything(),
expected,
);
},
);
it.each([
{
description: "source and target are the same",
source: customTag,
target: customTag,
},
{
description: "source custom section is not in the ordered list",
source: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
target: customTag,
},
{
description: "target custom section is not in the ordered list",
source: customTag,
target: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
},
{
description: "source is a default section",
source: DefaultTagID.Favourite,
target: customTag,
},
])("does nothing when $description", async ({ source, target }) => {
mockSettings([customTag], { [customTag]: { tag: customTag, name: "A" } });
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await reorderSection(source, target);
expect(setValueSpy).not.toHaveBeenCalled();
});
});
describe("isDefaultSectionTag", () => {
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
expect(isDefaultSectionTag(tag)).toBe(true);
@@ -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",
]);
});
});
});
});