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
+2 -2
View File
@@ -53,7 +53,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";
import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png";
@@ -371,7 +371,7 @@ export interface Settings {
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
}
export type SettingKey = keyof Settings;
@@ -36,11 +36,18 @@ import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
import { getChangedOverrideRoomMutePushRules } from "./utils";
import { isRoomVisible } from "./isRoomVisible";
import { RoomSkipList } from "./skip-list/RoomSkipList";
import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
import {
CHATS_TAG,
createSection,
deleteSection,
editSection,
getOrderedReorderableSections,
reorderSection,
} from "./section";
import { DefaultTagID } from "./skip-list/tag";
/**
* These are the filters passed to the room skip list.
@@ -513,6 +520,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
this.scheduleEmit();
}
/**
* Reorder custom sections by moving sourceTag to the position of targetTag.
* @param sourceTag The tag of the section to move
* @param targetTag The tag of the section to move to
*/
public async reorderSection(sourceTag: string, targetTag: string): Promise<void> {
await reorderSection(sourceTag, targetTag);
}
/**
* Returns the ordered section tags.
*/
@@ -524,8 +540,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* Load the custom sections from the settings store and update the sorted tags.
*/
private loadCustomSections(): void {
const orderedCustomSections = getOrderedCustomSections();
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
// Favourite is pinned to the top and LowPriority to the bottom. Everything in between
// (custom sections + Chats) is user-reorderable.
const reorderable = getOrderedReorderableSections();
this.sortedTags = [DefaultTagID.Favourite, ...reorderable, DefaultTagID.LowPriority];
}
}
+67 -8
View File
@@ -82,11 +82,25 @@ function isValidCustomSection(value: unknown): value is CustomSection {
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
*/
export type CustomSectionsData = Record<CustomTag, CustomSection>;
/**
* Ordered list of custom section tags.
*/
export type OrderedCustomSections = CustomTag[];
/**
* Tags that can be reordered relative to each other (everything except Favourite and LowPriority,
* which are pinned to the top and bottom respectively).
*/
export type ReorderableSection = CustomTag | typeof CHATS_TAG;
/**
* Returns true if the given tag is a tag that can be reordered (custom section or the Chats tag).
*/
function isReorderableSection(tag: string, customData: CustomSectionsData): tag is ReorderableSection {
return tag === CHATS_TAG || (isCustomSectionTag(tag) && tag in customData);
}
/**
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
*/
@@ -121,12 +135,31 @@ export function getCustomSectionData(): CustomSectionsData {
/**
* Retrieves the ordered list of custom section tags from the settings.
* If the settings contain tags that are not present in the custom section data, they will be filtered out and the settings will be updated to remove the unknown tags.
*
* @knipignore - exported for tests
*/
export function getOrderedCustomSections(): OrderedCustomSections {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag) => tag in sectionData);
const orderedSections = Array.isArray(rawValue) ? rawValue : [];
return orderedSections.filter((tag): tag is CustomTag => isCustomSectionTag(tag) && tag in sectionData);
}
/**
* Returns the ordered list of reorderable section tags (custom sections + the Chats tag).
* Favourite and LowPriority are not included they are pinned at the top and bottom respectively.
*
* If `CHATS_TAG` is missing from the stored order (e.g. legacy data or a freshly created custom
* section), it is appended at the end so that custom sections sit above Chats by default.
*/
export function getOrderedReorderableSections(): ReorderableSection[] {
const sectionData = getCustomSectionData();
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
const stored = Array.isArray(rawValue) ? rawValue : [];
const result = stored.filter((tag): tag is ReorderableSection => isReorderableSection(tag, sectionData));
if (!result.includes(CHATS_TAG)) result.push(CHATS_TAG);
return result;
}
/**
@@ -150,10 +183,12 @@ export async function createSection(spaceId: SpaceKey): Promise<string | undefin
sectionData[tag] = newSection;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
// Add the new section to the ordered list of sections
const orderedSections = getOrderedCustomSections();
orderedSections.push(tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
// Add the new section to the ordered list of reorderable sections, just before CHATS_TAG
// so that newly-created sections appear above Chats by default.
const reorderable = getOrderedReorderableSections();
const chatsIndex = reorderable.indexOf(CHATS_TAG);
reorderable.splice(chatsIndex === -1 ? reorderable.length : chatsIndex, 0, tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, reorderable);
return tag;
}
@@ -204,11 +239,35 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
const [shouldRemoveSection] = await modal.finished;
if (!shouldRemoveSection) return;
// Remove the section from the ordered list of sections
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
// Remove the section from the ordered list of reorderable sections (preserves CHATS_TAG position)
const newOrderedSections = getOrderedReorderableSections().filter((sectionTag) => sectionTag !== tag);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);
// Remove the section data
delete sectionData[tag];
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}
/**
* Reorders sections by moving sourceTag relative to targetTag within the set of reorderable
* sections (custom sections and the Chats tag). Favourite and LowPriority are not reorderable
* and are rejected as either source or target.
*
* If the source was below the target, it is inserted before the target; otherwise after.
* @param sourceTag - The tag of the section to move.
* @param targetTag - The tag of the section to move relative to.
*/
export async function reorderSection(sourceTag: string, targetTag: string): Promise<void> {
const ordered = getOrderedReorderableSections();
const fromIndex = ordered.indexOf(sourceTag as ReorderableSection);
if (fromIndex === -1 || !ordered.includes(targetTag as ReorderableSection) || sourceTag === targetTag) return;
const toIndex = ordered.indexOf(targetTag as ReorderableSection);
const insertBefore = fromIndex > toIndex;
ordered.splice(fromIndex, 1);
const newToIndex = ordered.indexOf(targetTag as ReorderableSection);
ordered.splice(insertBefore ? newToIndex : newToIndex + 1, 0, sourceTag as ReorderableSection);
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered);
}
@@ -19,7 +19,12 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import {
CHATS_TAG,
getCustomSectionData,
isCustomSectionTag,
isDefaultSectionTag,
} from "../../stores/room-list-v3/section";
import PosthogTrackers from "../../PosthogTrackers";
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { type Call, CallEvent } from "../../models/Call";
@@ -62,6 +67,7 @@ export class RoomListSectionHeaderViewModel
isExpanded: true,
isUnread: false,
displaySectionMenu: !isDefaultSection,
canBeReordered: !isDefaultSection || props.tag === CHATS_TAG,
});
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
this.onCustomSectionDataChange(),
@@ -94,6 +94,11 @@ export class RoomListViewModel
private roomsMap = new Map<string, Room>();
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
/**
* When dragging sections, we want to temporarily expand all sections to make it easier to move rooms between sections.
* This map stores the original expansion state of each section before the drag starts, so we can restore it after the drag ends.
*/
private readonly savedExpansionStates = new Map<string, boolean>();
/**
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
@@ -663,6 +668,33 @@ export class RoomListViewModel
}, 15 * 1000);
}
public changeSectionOrder = async (sourceTag: string, targetTag: string): Promise<void> => {
await RoomListStoreV3.instance.reorderSection(sourceTag, targetTag);
// Scroll to the section after it moved
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
this.updateRoomsMap(this.roomsResult);
this.updateRoomListData(false, null, sourceTag);
};
public onSectionDragStart = (): void => {
this.savedExpansionStates.clear();
for (const [tag, sectionVM] of this.roomSectionHeaderViewModels) {
this.savedExpansionStates.set(tag, sectionVM.isExpanded);
sectionVM.isExpanded = false;
}
this.updateRoomListData();
};
public onSectionDragEnd = (): void => {
for (const [tag, expanded] of this.savedExpansionStates) {
const sectionVM = this.roomSectionHeaderViewModels.get(tag);
if (sectionVM) sectionVM.isExpanded = expanded;
}
this.savedExpansionStates.clear();
this.updateRoomListData();
};
public changeRoomSection = (roomId: string, tag: string): void => {
const room = this.props.client.getRoom(roomId);
if (!room) return;
@@ -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",
]);
});
});
});
});
@@ -413,8 +413,16 @@ export function useVirtualizedList<Item, Context>(
[handleRef],
);
// Key items by id, not position, so react-virtuoso preserves (moves) the existing DOM
// node when an item's absolute index shifts — e.g. sections collapsing on drag start removes
// the rooms above a header, shifting its index. Without this, Virtuoso's default key is the
// index, so the wrapper (and the focused header inside it) remounts, the roving-tabindex effect
// refocuses the fresh node, and screen readers re-announce the header mid-drag.
const computeItemKey = useCallback((_index: number, item: Item): string => getItemKey(item), [getItemKey]);
return {
...virtuosoProps,
computeItemKey,
ref: setRef,
scrollerRef,
onKeyDown: keyDownCallback,
@@ -103,6 +103,17 @@
"room_list": {
"a11y": {
"default": "Open room %(roomName)s",
"drag_cancelled": "Dragging cancelled",
"drag_end": "%(source)s was dropped on %(target)s",
"drag_end_after": "%(source)s was dropped after %(target)s",
"drag_end_before": "%(source)s was dropped before %(target)s",
"drag_end_original": "%(source)s returned to its original position",
"drag_instructions": "Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
"drag_over": "%(source)s is over %(target)s",
"drag_over_after": "%(source)s will be dropped after %(target)s",
"drag_over_before": "%(source)s will be dropped before %(target)s",
"drag_over_original": "%(source)s will return to its original position",
"drag_start": "Dragging %(source)s",
"invitation": "Open room %(roomName)s invitation.",
"mention": {
"one": "Open room %(roomName)s with 1 unread mention.",
@@ -172,7 +183,7 @@
"more_options": "More options",
"remove_section": "Remove section",
"toggle": "Toggle %(section)s section",
"toggle_unread": "Toggle %(section)s section with unread room(s)"
"toggle_unread": "Toggle %(section)s section with unread rooms"
},
"show_message_previews": "Show message previews",
"sort": "Sort",
@@ -41,6 +41,9 @@ const RoomListViewWrapperImpl = ({
renderAvatar: renderAvatarProp,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
...rest
}: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -52,6 +55,9 @@ const RoomListViewWrapperImpl = ({
updateVisibleRooms,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
};
@@ -105,6 +111,9 @@ const meta = {
toast: undefined,
closeToast: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
onSectionDragEnd: fn(),
},
parameters: {
design: {
@@ -10,10 +10,16 @@ import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import * as stories from "./RoomListView.stories";
// Stable UUIDs so snapshots don't change between runs.
let uuidCounter = 0;
vi.spyOn(crypto, "randomUUID").mockImplementation(
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, "0")}` as ReturnType<typeof crypto.randomUUID>,
);
const {
Default,
Loading,
@@ -77,6 +77,12 @@ export interface RoomListViewActions {
closeToast: () => void;
/** Called to change the section of a room */
changeRoomSection: (roomId: string, tag: string) => void;
/** Called to change the order of sections */
changeSectionOrder: (sourceTag: string, targetTag: string) => void;
/** Called when a section drag starts — collapses all sections */
onSectionDragStart: () => void;
/** Called when a section drag ends (drop or cancel) — restores expansion states */
onSectionDragEnd: () => void;
}
/**
@@ -8428,42 +8428,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
aria-setsize="23"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -8483,6 +8488,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room General"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -8640,6 +8646,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -8770,6 +8777,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Engineering"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -8906,6 +8914,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Design"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9036,6 +9045,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Product"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9172,6 +9182,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Marketing with 5 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9325,6 +9336,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Sales"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9461,6 +9473,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Support"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9591,6 +9604,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Announcements"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9727,6 +9741,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Off-topic"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9857,6 +9872,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Alpha with 10 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10016,6 +10032,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Beta"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10146,6 +10163,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project X"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -10282,6 +10300,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project Y"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10412,6 +10431,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Water Cooler"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10548,6 +10568,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Feedback with 15 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -10701,6 +10722,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Ideas"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10837,6 +10859,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Bugs"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10967,6 +10990,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Features"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11103,6 +11127,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Releases"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11233,6 +11258,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room General with 20 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11392,6 +11418,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11522,6 +11549,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Engineering"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11655,42 +11683,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
aria-setsize="29"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -11710,6 +11743,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Design"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11840,6 +11874,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Product"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11976,6 +12011,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Marketing with 25 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12129,6 +12165,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Sales"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12265,6 +12302,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Support"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -12395,6 +12433,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Announcements"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12531,6 +12570,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Off-topic"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12661,6 +12701,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Alpha with 30 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -12820,6 +12861,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Beta"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12950,6 +12992,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project X"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -13086,6 +13129,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project Y"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -13216,6 +13260,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Water Cooler"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -13880,42 +13925,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
aria-setsize="2"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -13935,6 +13985,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-haspopup="menu"
aria-label="Open room General"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -14092,6 +14143,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -14219,42 +14271,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
aria-setsize="0"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,437 @@
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@test-utils";
import {
type A11yData,
type DragAnnouncementGetter,
RoomListAccessibilityPlugin,
type RoomListAccessibilityOptions,
useRoomListAccessibilityPlugin,
} from "./RoomListAccessibilityPlugin";
import { I18nContext } from "../../core/i18n/i18nContext";
import { I18nApi } from "../../core/i18n/I18nApi";
import type { RoomListViewModel } from "../RoomListView";
// ---------------------------------------------------------------------------
// Minimal mock manager compatible with the @dnd-kit/abstract Plugin base class
// ---------------------------------------------------------------------------
type EventHandler = (event: unknown) => void;
function createMockManager(): {
monitor: { addEventListener: ReturnType<typeof vi.fn> };
registry: {
draggables: { readonly value: IterableIterator<{ handle: HTMLElement | null; element: HTMLElement | null }> };
};
dispatch: (eventName: string, event: unknown) => void;
draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[];
} {
const listeners = new Map<string, EventHandler[]>();
const monitor = {
addEventListener: vi.fn((eventName: string, handler: EventHandler) => {
if (!listeners.has(eventName)) listeners.set(eventName, []);
listeners.get(eventName)!.push(handler);
return vi.fn(() => {
const fns = listeners.get(eventName);
if (fns) {
const idx = fns.indexOf(handler);
if (idx >= 0) fns.splice(idx, 1);
}
});
}),
};
// A list of fake draggable objects the effect iterates over.
const draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[] = [];
const registry = {
draggables: {
// Plain (non-reactive) getter the effect runs once on construction.
get value() {
return draggableElements.values();
},
},
};
/** Trigger a monitor event on all registered handlers. */
const dispatch = (eventName: string, event: unknown): void => {
listeners.get(eventName)?.forEach((fn) => fn(event));
};
return { monitor, registry, dispatch, draggableElements };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createPlugin(
manager: ReturnType<typeof createMockManager>,
options?: RoomListAccessibilityOptions,
): RoomListAccessibilityPlugin {
// RoomListAccessibilityPlugin extends Plugin<DragDropManager> from @dnd-kit/abstract.
// The base class only requires manager.monitor and manager.registry to exist, which our
// mock satisfies.
return new RoomListAccessibilityPlugin(manager as never, options);
}
function getLiveRegion(): HTMLElement | null {
return document.querySelector<HTMLElement>("[role='status'][aria-live='polite']");
}
function getAssertiveRegion(): HTMLElement | null {
return document.querySelector<HTMLElement>("[role='alert'][aria-live='assertive']");
}
function getInstructions(): HTMLElement | null {
return document.querySelector<HTMLElement>("[style*='display: none']");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RoomListAccessibilityPlugin", () => {
let manager: ReturnType<typeof createMockManager>;
beforeEach(() => {
manager = createMockManager();
});
afterEach(() => {
// Clean up any DOM nodes left behind by tests that don't call destroy().
getLiveRegion()?.remove();
getAssertiveRegion()?.remove();
getInstructions()?.remove();
});
describe("construction", () => {
it("appends a polite aria-live region to the document body", () => {
const plugin = createPlugin(manager);
const region = getLiveRegion();
expect(region).not.toBeNull();
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("role", "status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("aria-atomic", "true");
plugin.destroy();
});
it("appends an assertive aria-live region to the document body", () => {
const plugin = createPlugin(manager);
const region = getAssertiveRegion();
expect(region).not.toBeNull();
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("role", "alert");
expect(region).toHaveAttribute("aria-live", "assertive");
expect(region).toHaveAttribute("aria-atomic", "true");
plugin.destroy();
});
it("appends a hidden instructions element when the instructions option is provided", () => {
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
const el = getInstructions();
expect(el).not.toBeNull();
expect(el).toBeInTheDocument();
expect(el?.textContent).toBe("Press Space to drag");
expect(el?.style.display).toBe("none");
plugin.destroy();
});
it("does not append an instructions element when the option is omitted", () => {
const plugin = createPlugin(manager);
expect(getInstructions()).toBeNull();
plugin.destroy();
});
it("adds aria-describedby pointing to the instructions element on existing draggables", () => {
const button = document.createElement("button");
button.setAttribute("aria-label", "Toggle Favourites section");
document.body.append(button);
manager.draggableElements.push({ handle: button, element: button });
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
// The ID is a generated UUID — verify the button points to the instructions element.
const instructionsId = button.getAttribute("aria-describedby");
expect(instructionsId).toBeTruthy();
expect(document.getElementById(instructionsId!)).toBe(getInstructions());
button.remove();
plugin.destroy();
});
it("does not overwrite an existing aria-describedby on a draggable", () => {
const button = document.createElement("button");
button.setAttribute("aria-describedby", "my-existing-id");
document.body.append(button);
manager.draggableElements.push({ handle: button, element: button });
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
expect(button).toHaveAttribute("aria-describedby", "my-existing-id");
button.remove();
plugin.destroy();
});
});
describe("dragstart and dragover announcements", () => {
it("writes a dragstart message to the live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragstart: () => "Dragging Favourites" },
});
manager.dispatch("dragstart", {});
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
plugin.destroy();
});
it("writes a dragover message to the live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragover: () => "Dragging Favourites over Low Priority" },
});
manager.dispatch("dragover", {});
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites over Low Priority");
plugin.destroy();
});
it("does not update the live region when the getter returns undefined", () => {
const plugin = createPlugin(manager, {
announcements: { dragstart: () => undefined },
});
manager.dispatch("dragstart", {});
expect(getLiveRegion()?.textContent).toBe("");
plugin.destroy();
});
it("does not update the live region when the message is the same as the current text", () => {
const get = vi.fn(() => "Dragging Favourites");
const plugin = createPlugin(manager, {
announcements: { dragstart: get },
});
manager.dispatch("dragstart", {});
manager.dispatch("dragstart", {});
// The getter was called twice but the live region text is set only once (dedup).
expect(get).toHaveBeenCalledTimes(2);
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
plugin.destroy();
});
it("passes the raw dnd-kit event to the announcement getter", () => {
const getter = vi.fn(() => "Dragging Favourites");
const plugin = createPlugin(manager, {
announcements: { dragstart: getter },
});
const fakeEvent = { operation: { source: { id: "fav" } } };
manager.dispatch("dragstart", fakeEvent);
expect(getter).toHaveBeenCalledWith(fakeEvent);
plugin.destroy();
});
});
describe("dragend announcement", () => {
it("announces the drop message in the assertive live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => "Favourites was dropped on Low Priority" },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
// The drop is announced in the assertive region: focus stays on the source element so
// there is no focus change to read, and an assertive region reliably announces on Chrome.
expect(getAssertiveRegion()?.textContent).toBe("Favourites was dropped on Low Priority");
plugin.destroy();
});
it("re-announces an identical message by clearing the region first", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => "Dropped" },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
// The same text set twice must still end up in the region (clear-then-set forces a change).
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
plugin.destroy();
});
it("does not announce when the getter returns undefined", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => undefined },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
// Both live regions stay empty.
expect(getLiveRegion()?.textContent).toBe("");
expect(getAssertiveRegion()?.textContent).toBe("");
plugin.destroy();
});
});
describe("destroy", () => {
it("removes the live region from the document", () => {
const plugin = createPlugin(manager);
expect(getLiveRegion()).not.toBeNull();
plugin.destroy();
expect(getLiveRegion()).toBeNull();
});
it("removes the assertive live region from the document", () => {
const plugin = createPlugin(manager);
expect(getAssertiveRegion()).not.toBeNull();
plugin.destroy();
expect(getAssertiveRegion()).toBeNull();
});
it("removes the instructions element from the document", () => {
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
expect(getInstructions()).not.toBeNull();
plugin.destroy();
expect(getInstructions()).toBeNull();
});
it("calls the unsubscribe functions returned by monitor.addEventListener", () => {
// Capture the unsubscribe function spy that the mock returns.
let unsubscribeSpy: ReturnType<typeof vi.fn> | undefined;
manager.monitor.addEventListener.mockImplementation((_eventName: string, _handler: EventHandler) => {
unsubscribeSpy = vi.fn();
return unsubscribeSpy as unknown as ReturnType<typeof vi.fn<() => void>>;
});
const plugin = createPlugin(manager, {
announcements: { dragstart: () => "Dragging" },
});
plugin.destroy();
expect(unsubscribeSpy).toHaveBeenCalled();
});
});
describe("useRoomListAccessibilityPlugin announcements", () => {
const SECTION_TITLES: Record<string, string> = {
work: "Work",
fun: "Fun",
};
const ROOM_NAMES: Record<string, string> = {
"!room:server": "My Room",
};
function createMockVm(): RoomListViewModel {
return {
getSectionHeaderViewModel: (id: string) => ({
getSnapshot: () => ({ title: SECTION_TITLES[id] ?? id }),
}),
getRoomItemViewModel: (id: string) => ({
getSnapshot: () => ({ name: ROOM_NAMES[id] }),
}),
} as unknown as RoomListViewModel;
}
/** Render the hook and return the announcement getters it configures on the plugin. */
function getAnnouncements(
vm: RoomListViewModel,
): Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>> {
const wrapper = ({ children }: { children: React.ReactNode }): React.ReactNode =>
React.createElement(I18nContext.Provider, { value: new I18nApi() }, children);
const { result } = renderHook(() => useRoomListAccessibilityPlugin(vm), { wrapper });
const descriptor = result.current([]).find(
(
plugin,
): plugin is {
plugin: typeof RoomListAccessibilityPlugin;
options: RoomListAccessibilityOptions;
} => typeof plugin === "object" && plugin.plugin === RoomListAccessibilityPlugin,
);
return descriptor!.options.announcements!;
}
const sectionSource = (id: string, index: number): A11yData["operation"]["source"] =>
({ id, data: { type: "section", index } }) as A11yData["operation"]["source"];
const sectionTarget = (id: string, index: number): A11yData["operation"]["target"] =>
({ id, data: { type: "section", index } }) as A11yData["operation"]["target"];
it("announces a section will return to its original position when dragged over a non-droppable area", () => {
const { dragover } = getAnnouncements(createMockVm());
const message = dragover!({
operation: { source: sectionSource("work", 1), target: null },
canceled: false,
});
expect(message).toBe("Work will return to its original position");
});
it("announces a section returned to its original position when dropped on a non-droppable area", () => {
const { dragend } = getAnnouncements(createMockVm());
const message = dragend!({
operation: { source: sectionSource("work", 1), target: null },
canceled: false,
});
expect(message).toBe("Work returned to its original position");
});
it("still announces the before/after target when a section is dragged over another section", () => {
const { dragover, dragend } = getAnnouncements(createMockVm());
// Source index 2 dropped onto target index 1 → dropped before the target.
const event: A11yData = {
operation: { source: sectionSource("fun", 2), target: sectionTarget("work", 1) },
canceled: false,
};
expect(dragover!(event)).toBe("Fun will be dropped before Work");
expect(dragend!(event)).toBe("Fun was dropped before Work");
});
it("announces cancellation even when there is no target", () => {
const { dragend } = getAnnouncements(createMockVm());
const message = dragend!({
operation: { source: sectionSource("work", 1), target: null },
canceled: true,
});
expect(message).toBe("Dragging cancelled");
});
});
});
@@ -0,0 +1,276 @@
/*
* 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 { useCallback, useMemo } from "react";
import { configure, Plugin, type Plugins } from "@dnd-kit/abstract";
import { Accessibility, type Draggable, type DragDropManager, type Droppable } from "@dnd-kit/dom";
import { useI18n } from "../../core/i18n/i18nContext";
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
import type { RoomListViewModel } from "../RoomListView";
type Manager = DragDropManager;
/** Shape of the dnd-kit monitor event passed to the announcement getters. */
export type A11yData = {
operation: {
source: Draggable<RoomListDragData> | null;
target: Droppable<RoomListDragData> | null;
};
canceled: boolean;
};
/**
* Produces the screen-reader announcement for a drag lifecycle event, or `undefined`
* to stay silent. The event exposes `operation.source`, `operation.target` and (on
* `dragend`) `canceled`.
*/
export type DragAnnouncementGetter = (event: A11yData) => string | undefined;
/**
* Options for {@link RoomListAccessibilityPlugin}.
*
* All fields are optional so the options type stays assignable to dnd-kit's generic
* `PluginOptions`.
*/
export interface RoomListAccessibilityOptions {
/** Announcement to emit for each drag lifecycle event. */
announcements?: Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>>;
/**
* Keyboard drag instructions read out when a draggable receives focus, wired to each
* draggable via `aria-describedby`.
*/
instructions?: string;
}
/**
* Create the visually-hidden `aria-live` region used to announce drag progress.
*
* @param politeness - `"polite"` for progress (start/over) updates, `"assertive"` for the terminal
* drop/cancel confirmation so it interrupts any pending progress chatter and is announced reliably.
*/
function createLiveRegion(id: string, politeness: "polite" | "assertive" = "polite"): HTMLDivElement {
const element = document.createElement("div");
element.id = id;
element.setAttribute("role", politeness === "assertive" ? "alert" : "status");
element.setAttribute("aria-live", politeness);
element.setAttribute("aria-atomic", "true");
Object.assign(element.style, {
position: "fixed",
width: "1px",
height: "1px",
margin: "-1px",
border: "0",
padding: "0",
overflow: "hidden",
clip: "rect(0 0 0 0)",
clipPath: "inset(100%)",
whiteSpace: "nowrap",
});
return element;
}
/**
* Create the hidden element holding the keyboard drag instructions. Only referenced via
* `aria-describedby` (never announced), so `display: none` is enough to hide it.
*/
function createInstructions(id: string, text: string): HTMLDivElement {
const element = document.createElement("div");
element.id = id;
element.style.display = "none";
element.textContent = text;
return element;
}
/**
* A dnd-kit plugin that manages the room list's drag-and-drop accessibility:
* - announces drag progress to screen readers via an `aria-live` region, and
* - exposes the keyboard drag instructions, wiring them to every draggable through
* `aria-describedby`.
*
* This is a deliberately reduced replacement for dnd-kit's built-in `Accessibility`
* plugin. The built-in plugin also reflects `aria-pressed`/`aria-grabbed` onto the
* draggable `<button>`, which VoiceOver reads as "selected" the instant a keyboard drag
* starts. We filter the built-in plugin out (see VirtualizedRoomListView) and use this
* instead: it never mutates the draggable's pressed/grabbed ARIA state.
*/
export class RoomListAccessibilityPlugin extends Plugin<Manager, RoomListAccessibilityOptions> {
private liveRegion: HTMLDivElement;
private assertiveRegion: HTMLDivElement;
private instructions?: HTMLDivElement;
private readonly unsubscribers: Array<() => void> = [];
public constructor(manager: Manager, options?: RoomListAccessibilityOptions) {
super(manager, options);
const liveRegionId = crypto.randomUUID();
const assertiveRegionId = crypto.randomUUID();
const instructionsId = crypto.randomUUID();
// Create the live regions up front so they exist in the DOM before any text change,
// which assistive technologies require to reliably announce the first message.
this.liveRegion = createLiveRegion(liveRegionId);
this.assertiveRegion = createLiveRegion(assertiveRegionId, "assertive");
document.body.append(this.liveRegion, this.assertiveRegion);
const announcements = options?.announcements ?? {};
for (const [eventName, getAnnouncement] of Object.entries(announcements)) {
if (!getAnnouncement) continue;
let unsubscribe: () => void;
if (eventName === "dragend") {
unsubscribe = manager.monitor.addEventListener("dragend", (event) => {
const a11yEvent = event as unknown as A11yData;
const message = getAnnouncement(a11yEvent);
// On drop the source element stays focused (focus never actually moves), so we need to announce the drop confirmation in the assertive region to ensure it is read.
this.announceAssertive(message);
});
} else {
unsubscribe = manager.monitor.addEventListener(eventName as "dragstart", (event) => {
this.announce(getAnnouncement(event as unknown as A11yData));
});
}
this.unsubscribers.push(unsubscribe);
}
if (options?.instructions) {
this.instructions = createInstructions(instructionsId, options.instructions);
document.body.append(this.instructions);
// Point every draggable at the instructions via aria-describedby. The effect re-runs
// as draggables register/unregister (the list is virtualized), so newly mounted items
// get described too. We never touch aria-pressed/aria-grabbed here.
this.registerEffect(() => {
if (!this.instructions) return;
for (const draggable of this.manager.registry.draggables.value) {
const activator = draggable.handle ?? draggable.element;
if (activator && !activator.hasAttribute("aria-describedby")) {
activator.setAttribute("aria-describedby", instructionsId);
}
}
});
}
}
private announce(message: string | undefined): void {
if (!message || this.liveRegion.textContent === message) return;
this.liveRegion.textContent = message;
}
/**
* Announce a terminal message (drop confirmation / cancellation) in the assertive live region.
*
* Unlike the polite progress region, the drop happens with focus parked on the source element
* and no focus change to trigger a re-read, so an assertive region is required for Chrome to
* announce it. It also interrupts any still-pending "… will be dropped …" progress chatter.
*/
private announceAssertive(message: string | undefined): void {
if (!message) return;
// Always re-set (clearing first) so an identical message still re-announces, and so the
// assertive region reliably fires even right after a polite progress update.
this.assertiveRegion.textContent = "";
this.assertiveRegion.textContent = message;
}
public destroy(): void {
super.destroy();
for (const unsubscribe of this.unsubscribers) unsubscribe();
this.unsubscribers.length = 0;
this.liveRegion.remove();
this.assertiveRegion.remove();
this.instructions?.remove();
}
}
/**
* Configures {@link RoomListAccessibilityPlugin} for the room list and returns a `plugins`
* callback for `DragDropProvider`.
*
* It swaps dnd-kit's built-in Accessibility plugin (which adds the `aria-pressed` that
* VoiceOver reads as "selected") for {@link RoomListAccessibilityPlugin}, supplying it with
* localized announcements derived from the room list view model. The result is memoized so
* the plugin descriptor stays stable across renders and the plugin isn't torn down and
* recreated.
*/
export function useRoomListAccessibilityPlugin(
vm: RoomListViewModel,
): (defaults: Plugins<Manager>) => Plugins<Manager> {
const { translate: _t } = useI18n();
// Get the display name of a draggable source: the section title for a section, or the
// room name for a room. Returns undefined if the source can't be resolved.
const getDragSourceName = useCallback(
(source: Draggable<RoomListDragData>): string | undefined => {
if (isSectionDragData(source.data)) {
return vm.getSectionHeaderViewModel(source.id as string).getSnapshot().title;
}
return vm.getRoomItemViewModel(source.id as string)?.getSnapshot().name;
},
[vm],
);
const announcements = useMemo(
() => ({
dragstart: ({ operation: { source } }: A11yData) => {
if (!source) return;
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
return _t("room_list|a11y|drag_start", { source: sourceName });
},
dragover: ({ operation: { source, target } }: A11yData) => {
if (!source) return;
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
if (isSectionDragData(source.data) && !target) {
return _t("room_list|a11y|drag_over_original", { source: sourceName });
}
if (!target) return;
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
const droppedBefore = source.data.index > target.data.index;
return droppedBefore
? _t("room_list|a11y|drag_over_before", { source: sourceName, target: targetTitle })
: _t("room_list|a11y|drag_over_after", { source: sourceName, target: targetTitle });
}
return _t("room_list|a11y|drag_over", { source: sourceName, target: targetTitle });
},
dragend: ({ operation: { source, target }, canceled }: A11yData) => {
if (!source) return;
if (canceled) return _t("room_list|a11y|drag_cancelled");
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
if (isSectionDragData(source.data) && !target) {
return _t("room_list|a11y|drag_end_original", { source: sourceName });
}
if (!target) return;
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
const droppedBefore = source.data.index > target.data.index;
return droppedBefore
? _t("room_list|a11y|drag_end_before", { source: sourceName, target: targetTitle })
: _t("room_list|a11y|drag_end_after", { source: sourceName, target: targetTitle });
}
return _t("room_list|a11y|drag_end", { source: sourceName, target: targetTitle });
},
}),
[vm, _t, getDragSourceName],
);
const instructions = _t("room_list|a11y|drag_instructions");
return useCallback(
(defaults) => [
// remove the built-in Accessibility plugin
...defaults.filter((plugin) => plugin !== Accessibility),
configure(RoomListAccessibilityPlugin, { announcements, instructions }),
],
[announcements, instructions],
);
}
@@ -35,7 +35,11 @@ export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlay
renderAvatar,
}: RoomListItemDragOverlayViewProps): JSX.Element {
return (
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
// dragged room isn't duplicated (the real, still-focused item already exposes it, and drag
// feedback is narrated via the dnd live-region announcements).
<Flex
aria-hidden={true}
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
gap="var(--cpd-space-3x)"
align="stretch"
@@ -14,6 +14,7 @@ import { useMergeRefs } from "react-merge-refs";
import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
import { useViewModel } from "../../../core/viewmodel";
import { type RoomDragData } from "../dragAndDrop";
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
/** Index of this room in the list */
@@ -76,8 +77,9 @@ function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
ref: draggableRef,
handleRef,
isDragSource,
} = useDraggable({
} = useDraggable<RoomDragData>({
id: item.id,
data: { type: "room" },
// We clone the item in the dnd overlay to avoid putting a hole in the list
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
@@ -0,0 +1,11 @@
/*
* 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.
*/
.dragOverlay {
padding-top: 0px;
padding-bottom: 0px;
}
@@ -0,0 +1,63 @@
/*
* 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, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type RoomListSectionHeaderViewSnapshot,
type RoomListSectionHeaderActions,
} from "../RoomListSectionHeaderView";
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
import { useMockedViewModel } from "../../../core/viewmodel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
type RoomListSectionHeaderDragOverlayProps = RoomListSectionHeaderViewSnapshot & RoomListSectionHeaderActions;
const RoomListSectionHeaderDragOverlayWrapperImpl = ({
onClick,
editSection,
removeSection,
...rest
}: RoomListSectionHeaderDragOverlayProps): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick, editSection, removeSection });
return <RoomListSectionHeaderDragOverlayView vm={vm} />;
};
const RoomListSectionHeaderDragOverlayWrapper = withViewDocs(
RoomListSectionHeaderDragOverlayWrapperImpl,
RoomListSectionHeaderDragOverlayView,
);
const meta = {
title: "Room List/RoomListSectionHeaderDragOverlayView",
component: RoomListSectionHeaderDragOverlayWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<Story />
</div>
),
],
args: {
id: "element.io.section.abc123",
title: "Work",
isExpanded: true,
isUnread: false,
displaySectionMenu: true,
canBeReordered: true,
onClick: fn(),
editSection: fn(),
removeSection: fn(),
},
} satisfies Meta<typeof RoomListSectionHeaderDragOverlayWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
/*
* 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 { render } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListSectionHeaderDragOverlayView.stories";
const { Default } = composeStories(stories);
describe("<RoomListSectionHeaderDragOverlayView /> stories", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,41 @@
/*
* 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 classNames from "classnames";
import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView";
import { RoomListSectionHeaderContent } from "../RoomListSectionHeaderView/RoomListSectionHeaderContent";
import headerStyles from "../RoomListSectionHeaderView/RoomListSectionHeaderView.module.css";
import styles from "./RoomListSectionHeaderDragOverlayView.module.css";
/**
* Props for {@link RoomListSectionHeaderDragOverlayView}.
*/
export interface RoomListSectionHeaderDragOverlayViewProps {
/** The section header view model — same one used by the real section header */
vm: RoomListSectionHeaderViewModel;
}
/**
* Visual clone of a section header rendered inside the dnd drag overlay.
*
* Reuses {@link RoomListSectionHeaderContent} for the inner layout so the
* floating clone matches a real section header.
*/
export const RoomListSectionHeaderDragOverlayView = memo(function RoomListSectionHeaderDragOverlayView({
vm,
}: RoomListSectionHeaderDragOverlayViewProps): JSX.Element {
return (
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
// dragged section's title isn't duplicated (the real, still-focused header already exposes
// it, and drag feedback is narrated via the dnd live-region announcements).
<div aria-hidden={true} className={classNames(headerStyles.header, styles.dragOverlay)}>
<RoomListSectionHeaderContent vm={vm} isDragging={true} />
</div>
);
});
@@ -0,0 +1,42 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListSectionHeaderDragOverlayView /> stories > renders Default story 1`] = `
<div>
<div
style="width: 320px; padding: 8px;"
>
<div
aria-hidden="true"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderDragOverlayView-module_dragOverlay"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container RoomListSectionHeaderView-module_dragging"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Work
</span>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,9 @@
/*
* 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 { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
export type { RoomListSectionHeaderDragOverlayViewProps } from "./RoomListSectionHeaderDragOverlayView";
@@ -0,0 +1,117 @@
/*
* 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, useState } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
import classNames from "classnames";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useViewModel } from "../../../core/viewmodel";
import { _t } from "../../../core/i18n/i18n";
import { Flex } from "../../../core/utils/Flex";
import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
import styles from "./RoomListSectionHeaderView.module.css";
import { NotificationDecoration } from "../RoomListItemWrapper/RoomListItemView";
/**
* Props for {@link RoomListSectionHeaderContent}.
*/
export interface RoomListSectionHeaderContentProps {
/** The section header view model */
vm: RoomListSectionHeaderViewModel;
/** Whether the section header is being dragged — hides the interactive menu when true */
isDragging?: boolean;
}
/**
* The inner content of a section header: chevron, title, and menu (or static menu icon when dragging).
* Used both inside the full {@link RoomListSectionHeaderView} and inside the drag overlay.
*/
export const RoomListSectionHeaderContent = memo(function RoomListSectionHeaderContent({
vm,
isDragging = false,
}: RoomListSectionHeaderContentProps): JSX.Element {
const { title, displaySectionMenu, notification, isExpanded } = useViewModel(vm);
return (
<Flex
className={classNames(styles.container, {
[styles.dragging]: isDragging,
})}
align="center"
justify="space-between"
gap="var(--cpd-space-2x)"
>
<Flex align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon
className={styles.chevron}
width="24px"
height="24px"
fill="var(--cpd-color-icon-secondary)"
/>
<span className={styles.title}>{title}</span>
</Flex>
{!isExpanded && notification && (
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...notification} />
</div>
)}
{displaySectionMenu && !isDragging && <MenuComponent vm={vm} />}
</Flex>
);
});
interface MenuComponentProps {
vm: RoomListSectionHeaderViewModel;
}
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|section_header|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
className={styles.menu}
tooltip={_t("room_list|section_header|more_options")}
aria-label={_t("room_list|section_header|more_options")}
size="24px"
style={{ padding: "2px" }}
color="var(--cpd-color-icon-primary)"
>
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
</IconButton>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
hideChevron={true}
Icon={EditIcon}
label={_t("room_list|section_header|edit_section")}
onSelect={() => vm.editSection()}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
hideChevron={true}
Icon={DeleteIcon}
label={_t("room_list|section_header|remove_section")}
onSelect={() => vm.removeSection()}
onClick={(evt) => evt.stopPropagation()}
/>
</div>
</Menu>
);
}
@@ -65,6 +65,7 @@
}
.container {
position: relative;
margin: 0 var(--cpd-space-3x);
padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x);
border-radius: 8px;
@@ -99,10 +100,38 @@
padding-bottom: 0;
}
.dropTarget {
.dropTarget .container {
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
}
.dropTargetBottom .container::after,
.dropTargetTop .container::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background-color: var(--cpd-color-border-accent-primary);
}
.dropTargetBottom .container::after {
bottom: 0;
}
.dropTargetTop .container::before {
top: 0;
}
.menu {
display: none;
}
.dragging {
outline: 1px solid var(--cpd-color-border-interactive-hovered);
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 95%, transparent);
color: var(--cpd-color-text-primary);
}
.dragSource .container {
opacity: 0.6;
}
@@ -7,6 +7,8 @@
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { DragDropProvider } from "@dnd-kit/react";
import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
@@ -71,6 +73,7 @@ const meta = {
muted: false,
},
displaySectionMenu: true,
canBeReordered: true,
onClick: fn(),
onFocus: fn(),
editSection: fn(),
@@ -82,9 +85,17 @@ const meta = {
},
decorators: [
(Story) => (
<div role="treegrid" style={{ width: "320px" }}>
<Story />
</div>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
}),
]}
>
<div role="treegrid" style={{ width: "320px" }}>
<Story />
</div>
</DragDropProvider>
),
],
parameters: {
@@ -33,14 +33,14 @@ describe("<RoomListSectionHeaderView /> stories", () => {
const user = userEvent.setup();
render(<Default />);
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
const button = screen.getByRole("button", { name: HEADER_NAME });
await user.click(button);
expect(Default.args.onClick).toHaveBeenCalled();
});
it("focuses the button when isFocused is true", () => {
render(<Default isFocused={true} />);
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
const button = screen.getByRole("button", { name: HEADER_NAME });
expect(document.activeElement).toBe(button);
});
@@ -5,24 +5,20 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { memo, type JSX, type FocusEvent, useEffect, useRef, useState } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
import React, { memo, type JSX, type FocusEvent, useEffect, useRef } from "react";
import classNames from "classnames";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useDroppable } from "@dnd-kit/react";
import { useDraggable, useDragOperation, useDroppable } from "@dnd-kit/react";
import { useMergeRefs } from "react-merge-refs";
import { Feedback } from "@dnd-kit/dom";
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
import styles from "./RoomListSectionHeaderView.module.css";
import { Flex } from "../../../core/utils/Flex";
import { useI18n } from "../../../core/i18n/i18nContext";
import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList";
import { _t } from "../../../core/i18n/i18n";
import {
NotificationDecoration,
type NotificationDecorationData,
} from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
import { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
import { isSectionDragData, type RoomListDragData, type SectionDragData } from "../dragAndDrop";
import { type NotificationDecorationData } from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
/**
* The observable state snapshot for a room list section header.
@@ -40,6 +36,8 @@ export interface RoomListSectionHeaderViewSnapshot {
notification?: NotificationDecorationData;
/** Wether to display the section menu */
displaySectionMenu: boolean;
/** Whether the section can be reordered via drag-and-drop */
canBeReordered: boolean;
}
/**
@@ -108,14 +106,61 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
roomCountInSection,
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { id, title, isExpanded, isUnread, notification, displaySectionMenu } = useViewModel(vm);
const { id, title, isExpanded, isUnread, canBeReordered } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
const { ref: droppableRef, isDropTarget } = useDroppable({
const {
ref: draggableRef,
handleRef,
isDragSource,
} = useDraggable<SectionDragData>({
id,
data: { type: "section", index: sectionIndex },
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
disabled: !canBeReordered,
});
const { source } = useDragOperation<RoomListDragData>();
const draggedData = source?.data;
const isDraggingSectionSource = isSectionDragData(draggedData);
// Keep the droppable enabled so rooms can still be dropped on default sections
// (Favourite / Low Priority). Only disable it for section drags on non-reorderable
// headers so they can't be used as reorder targets.
const { ref: droppableRef, isDropTarget } = useDroppable<SectionDragData>({
id,
data: { type: "section", index: sectionIndex },
disabled: isDragSource || (isDraggingSectionSource && !canBeReordered),
});
const isDraggingRoom = isDropTarget && draggedData?.type === "room";
const isDraggingSection = isDropTarget && isDraggingSectionSource;
const sourceSectionIndex = isSectionDragData(draggedData) ? draggedData.index : -1;
const isSourceAbove = isDraggingSection && sourceSectionIndex > sectionIndex;
const hasBottomBorder = isDraggingSection && !isSourceAbove;
const hasTopBorder = isDraggingSection && isSourceAbove;
// Keep the last expanded state we rendered while NOT dragging.
const lastExpandedRef = useRef(isExpanded);
if (!isDragSource) {
lastExpandedRef.current = isExpanded;
}
// While this header is the drag source, freeze aria-expanded at its pre-drag value. Section
// drag start collapses every section, flipping this focused header's aria-expanded true→false;
// announcing that state change is a second, redundant screen-reader announcement on top of the
// dnd "Dragging X" live-region announcement. The collapse is still reflected visually.
const ariaExpanded = isDragSource ? lastExpandedRef.current : isExpanded;
const internalRef = useRef<HTMLButtonElement>(null);
const mergedRef = useMergeRefs<HTMLButtonElement>([droppableRef, internalRef]);
// Only wire up draggable refs when the section can be dragged. Otherwise dndkit will put incorrect and misleading a11y attributes
// on the default section (aka aria-disabled=true and aria-draggable=false)
const buttonRef = useMergeRefs([
...(canBeReordered ? [draggableRef, handleRef] : []),
droppableRef,
internalRef,
]) as React.Ref<HTMLButtonElement>;
useEffect(() => {
if (isFocused) {
@@ -125,128 +170,54 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
return (
<div
aria-expanded={isExpanded}
aria-expanded={ariaExpanded}
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
>
<button
ref={mergedRef}
type="button"
role="gridcell"
className={classNames(styles.header, {
[styles.firstHeader]: sectionIndex === 0,
// If the section is collapsed and it's the last one
[styles.lastHeader]: !isExpanded && isLastSection,
[styles.unread]: isUnread,
})}
onClick={vm.onClick}
onKeyDown={(e) => {
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
e.preventDefault();
e.stopPropagation();
vm.onClick();
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
// Move focus to the first room in the section
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowDown",
key: "ArrowDown",
bubbles: true,
}),
);
}
}}
aria-expanded={isExpanded}
onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1}
aria-label={
isUnread
? _t("room_list|section_header|toggle_unread", { section: title })
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex
className={classNames(styles.container, {
[styles.dropTarget]: isDropTarget,
<div role="gridcell" aria-expanded={ariaExpanded}>
<button
ref={buttonRef}
type="button"
className={classNames(styles.header, {
[styles.firstHeader]: sectionIndex === 0,
// If the section is collapsed and it's the last one
[styles.lastHeader]: !isExpanded && isLastSection,
[styles.unread]: isUnread,
[styles.dragSource]: isDragSource,
[styles.dropTarget]: isDraggingRoom,
[styles.dropTargetBottom]: hasBottomBorder,
[styles.dropTargetTop]: hasTopBorder,
})}
align="center"
justify="space-between"
gap="var(--cpd-space-2x)"
onClick={() => !isDragSource && vm.onClick()}
onKeyDown={(e) => {
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
e.preventDefault();
e.stopPropagation();
vm.onClick();
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
// Move focus to the first room in the section
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowDown",
key: "ArrowDown",
bubbles: true,
}),
);
}
}}
aria-expanded={ariaExpanded}
onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1}
aria-label={
isUnread
? _t("room_list|section_header|toggle_unread", { section: title })
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon
className={styles.chevron}
width="24px"
height="24px"
fill="var(--cpd-color-icon-secondary)"
/>
<span className={styles.title}>{title}</span>
</Flex>
{!isExpanded && notification && (
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...notification} />
</div>
)}
{displaySectionMenu && <MenuComponent vm={vm} />}
</Flex>
</button>
<RoomListSectionHeaderContent vm={vm} />
</button>
</div>
</div>
);
});
interface MenuComponentProps {
vm: RoomListSectionHeaderViewModel;
}
/**
*
* Menu component for the section header.
*/
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|section_header|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
className={styles.menu}
tooltip={_t("room_list|section_header|more_options")}
aria-label={_t("room_list|section_header|more_options")}
size="24px"
style={{ padding: "2px" }}
color="var(--cpd-color-icon-primary)"
>
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
</IconButton>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
hideChevron={true}
Icon={EditIcon}
label={_t("room_list|section_header|edit_section")}
onSelect={() => vm.editSection()}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
hideChevron={true}
Icon={DeleteIcon}
label={_t("room_list|section_header|remove_section")}
onSelect={() => vm.removeSection()}
onClick={(evt) => evt.stopPropagation()}
/>
</div>
</Menu>
);
}
@@ -14,75 +14,79 @@ exports[`<RoomListSectionHeaderView /> stories > renders Default story 1`] = `
aria-setsize="5"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More options"
aria-labelledby="react-use-id-1"
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
color="var(--cpd-color-icon-primary)"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-2"
role="button"
style="--cpd-icon-button-size: 24px; padding: 2px;"
tabindex="0"
type="button"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-primary)"
height="1em"
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="1em"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</button>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More options"
aria-labelledby="react-use-id-1"
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
color="var(--cpd-color-icon-primary)"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-2"
role="button"
style="--cpd-icon-button-size: 24px; padding: 2px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="var(--cpd-color-icon-primary)"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
</button>
</div>
</div>
</div>
</div>
@@ -6,6 +6,8 @@
*/
export { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
export { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
export type { RoomListSectionHeaderContentProps } from "./RoomListSectionHeaderContent";
export type {
RoomListSectionHeaderViewModel,
RoomListSectionHeaderViewSnapshot,
@@ -37,6 +37,9 @@ const RoomListWrapperImpl = ({
closeToast,
renderAvatar: renderAvatarProp,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
...rest
}: RoomListStoryProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -48,6 +51,9 @@ const RoomListWrapperImpl = ({
updateVisibleRooms,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
});
return (
@@ -88,6 +94,9 @@ const meta = {
isFlatList: true,
closeToast: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
onSectionDragEnd: fn(),
},
parameters: {
design: {
@@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./VirtualizedRoomListView.stories";
import { KEYBOARD_DRAG_OFFSET } from "./VirtualizedRoomListView";
const { Default, Sections } = composeStories(stories);
@@ -69,15 +70,19 @@ describe("<VirtualizedRoomListView />", () => {
describe("drag and drop", () => {
beforeEach(() => {
// Storybook fn() spies are shared across tests; vi.clearAllMocks() may not
// reach them, so explicitly reset call history for the spy under test.
// reach them, so explicitly reset call history for the spies under test.
(Sections.args.changeRoomSection as any).mockClear?.();
(Sections.args.changeSectionOrder as any).mockClear?.();
(Sections.args.onSectionDragStart as any).mockClear?.();
(Sections.args.onSectionDragEnd as any).mockClear?.();
});
it("should call changeRoomSection when drag ends successfully", async () => {
// KeyboardSensor: Space=start, ArrowDown moves position 10px/press, Space=drop.
// "General" (room 0) center is ~78px below the container top; "chats" section
// header starts ~130px below that. 15 presses × 10px = 150px → drag position
// enters the "chats" header area, making it the active droppable target.
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~150px down from "General"
// (room 0) so the drag position enters the target section header's droppable area;
// derive the keypress count from the offset so this stays correct if the offset changes.
const presses = Math.round(150 / KEYBOARD_DRAG_OFFSET);
const user = userEvent.setup();
renderWithMockContext(<Sections />);
@@ -86,8 +91,8 @@ describe("<VirtualizedRoomListView />", () => {
await user.keyboard(" "); // start drag
for (let i = 0; i < 15; i++) {
await user.keyboard("{ArrowDown}"); // move down 10px per press
for (let i = 0; i < presses; i++) {
await user.keyboard("{ArrowDown}");
}
await user.keyboard(" "); // drop onto current target
@@ -96,6 +101,79 @@ describe("<VirtualizedRoomListView />", () => {
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
});
});
it("does not reflect aria-pressed onto draggable room items or section headers", async () => {
// dnd-kit's built-in Accessibility plugin reflects aria-pressed onto the draggable
// <button>, which VoiceOver reads as "selected" when a keyboard drag starts. We drop
// that plugin, so the attribute must never appear (before or during a drag).
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const roomButton = await screen.findByRole("button", { name: "Open room General" });
const sectionHeader = await screen.findByLabelText("Toggle Favourites section");
expect(roomButton).not.toHaveAttribute("aria-pressed");
expect(sectionHeader).not.toHaveAttribute("aria-pressed");
roomButton.focus();
await user.keyboard(" "); // start drag
expect(roomButton).not.toHaveAttribute("aria-pressed");
await user.keyboard("{Escape}"); // cancel drag
});
it("announces drag progress in a live region", async () => {
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const status = screen.getByRole("status");
expect(status).toHaveTextContent("");
const roomButton = await screen.findByRole("button", { name: "Open room General" });
roomButton.focus();
await user.keyboard(" "); // start drag
await waitFor(() => expect(status).toHaveTextContent("Dragging General"));
await user.keyboard("{Escape}"); // cancel
});
it("exposes keyboard drag instructions referenced by draggable items", async () => {
renderWithMockContext(<Sections />);
// The plugin creates a hidden instructions element and wires draggables to it.
const instructions = screen.getByText(
"Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
);
const roomButton = await screen.findByRole("button", { name: "Open room General" });
await waitFor(() => expect(roomButton).toHaveAttribute("aria-describedby", instructions.id));
});
it("should reorder sections via keyboard", async () => {
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~200px down from the
// "Favourites" section header to land on the "low-priority" section header — a valid
// section reorder; derive the keypress count from the offset so this stays correct
// if the offset changes.
const presses = Math.round(200 / KEYBOARD_DRAG_OFFSET);
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const favouritesHeader = await screen.findByLabelText("Toggle Favourites section");
favouritesHeader.focus();
await user.keyboard(" "); // start drag
for (let i = 0; i < presses; i++) {
await user.keyboard("{ArrowDown}");
}
await user.keyboard(" "); // drop
await waitFor(() => {
expect(Sections.args.changeSectionOrder).toHaveBeenCalledWith("favourites", "low-priority");
});
expect(Sections.args.onSectionDragStart).toHaveBeenCalled();
expect(Sections.args.onSectionDragEnd).toHaveBeenCalled();
});
});
describe("scrollToSectionTag", () => {
@@ -13,7 +13,6 @@ import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dn
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
import { useViewModel } from "../../core/viewmodel";
import { _t } from "../../core/i18n/i18n";
import {
FlatVirtualizedList,
getContainerAccessibleProps,
@@ -22,9 +21,13 @@ import {
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
import { RoomListItemWrapper } from "./RoomListItemWrapper";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
import { useRoomListAccessibilityPlugin } from "./RoomListAccessibilityPlugin";
import styles from "./VirtualizedRoomListView.module.css";
import { useI18n } from "../../core/i18n/i18nContext";
/**
* Filter key type - opaque string type for filter identifiers
@@ -69,6 +72,11 @@ export interface VirtualizedRoomListViewProps {
/** Height of a single room list item in pixels (44px item + 8px padding bottom) */
const ROOM_LIST_ITEM_HEIGHT = 52;
/**
* Number of pixels the keyboard sensor moves the dragged element per arrow keypress.
*/
export const KEYBOARD_DRAG_OFFSET = 17;
/**
* Type for context used in ListView
*/
@@ -112,6 +120,7 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
* ```
*/
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
const { translate: _t } = useI18n();
const snapshot = useViewModel(vm);
const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
@@ -147,6 +156,10 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[vm],
);
// Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,
// replacing dnd-kit's built-in Accessibility plugin.
const a11yPlugins = useRoomListAccessibilityPlugin(vm);
/**
* Get the item component for a specific index
* Gets the room's view model and passes it to RoomListItemView
@@ -388,13 +401,25 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
}
return (
<DragDropProvider
<DragDropProvider<RoomListDragData>
onDragStart={(event) => {
const { source } = event.operation;
// Changing the state of sections (collapsed/expanded) while dragging a section header causes a double readback for the a11y announcement.
if (isSectionDragData(source?.data)) {
vm.onSectionDragStart();
}
}}
onDragEnd={(event) => {
if (event.canceled) return;
const { target, source } = event.operation;
if (!source || !target) return;
vm.changeRoomSection(source.id as string, target.id as string);
const { source, target } = event.operation;
if (isSectionDragData(source?.data)) {
vm.onSectionDragEnd();
}
if (event.canceled || !source || !target) return;
if (isSectionDragData(source.data)) {
vm.changeSectionOrder(String(source.id), String(target.id));
} else {
vm.changeRoomSection(String(source.id), String(target.id));
}
}}
sensors={[
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
@@ -404,6 +429,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
}),
// By default, the KeyboardSensor uses both space and enter to start dragging, which interferes with the keyboard enter shortcut to open a room.
KeyboardSensor.configure({
// The default 10px-per-keypress offset makes keyboard dragging feel sluggish.
offset: KEYBOARD_DRAG_OFFSET,
keyboardCodes: {
start: ["Space"],
cancel: ["Escape"],
@@ -415,6 +442,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
},
}),
]}
plugins={a11yPlugins}
>
<DragOverlay dropAnimation={null}>
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
@@ -439,7 +467,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
*/
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
const { source } = useDragOperation();
const { source } = useDragOperation<RoomListDragData>();
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
}
@@ -455,10 +483,15 @@ interface DragOverlayContentProps {
* Component rendered in the drag overlay when dragging a room item. Renders a copy of the dragged item to avoid dragging the actual element out of virtualization.
*/
function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX.Element | null {
const { source } = useDragOperation();
const { source } = useDragOperation<RoomListDragData>();
if (!source) return null;
const itemVm = vm.getRoomItemViewModel(source.id as string);
if (isSectionDragData(source.data)) {
const sectionHeaderVM = vm.getSectionHeaderViewModel(String(source.id));
return <RoomListSectionHeaderDragOverlayView vm={sectionHeaderVM} />;
}
const itemVm = vm.getRoomItemViewModel(String(source.id));
if (!itemVm) return null;
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
@@ -0,0 +1,20 @@
/*
* 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.
*/
/** Drag payload for a section header. */
export type SectionDragData = { type: "section"; index: number };
/** Drag payload for a room item. */
export type RoomDragData = { type: "room" };
/** Discriminated union of all drag payloads in the room list. */
export type RoomListDragData = SectionDragData | RoomDragData;
/**
* Type guard: true when the drag source is a section header. Narrows to {@link SectionDragData}.
*/
export function isSectionDragData(data: RoomListDragData | undefined): data is SectionDragData {
return data?.type === "section";
}
@@ -151,6 +151,9 @@ export const createGetSectionHeaderViewModel = (
id: sectionId,
title: sectionId[0].toUpperCase() + sectionId.slice(1),
isExpanded: true,
isUnread: false,
displaySectionMenu: false,
canBeReordered: true,
};
const vm = new MockViewModel(snapshot) as unknown as RoomListSectionHeaderViewModel;
Object.assign(vm, {
@@ -117,6 +117,7 @@ export default defineConfig({
"vite-plugin-node-polyfills/shims/process",
"@vector-im/compound-design-tokens/assets/web/icons",
"storybook/preview-api",
"@dnd-kit/abstract",
],
},
resolve: {