diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index 90eeccbb56..20c087760c 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -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" }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts index 57bdbec794..78d2a6c032 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts @@ -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 { + 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 { + 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 diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index e36cfc6069..e666950daf 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -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; "Developer.elementCallUrl": IBaseSetting; "RoomList.CustomSectionData": IBaseSetting; - "RoomList.OrderedCustomSections": IBaseSetting; + "RoomList.OrderedCustomSections": IBaseSetting; } export type SettingKey = keyof Settings; diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index a85e5cd28e..5514745f10 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -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 { 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 { + await reorderSection(sourceTag, targetTag); + } + /** * Returns the ordered section tags. */ @@ -524,8 +540,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient { * 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]; } } diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 1cde7e6a04..ea1fc8927e 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -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; + /** * 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 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 { + 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); +} diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index 87630425b6..963b24e6a2 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -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(), diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 926735ccfc..c056b43d7d 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -94,6 +94,11 @@ export class RoomListViewModel private roomsMap = new Map(); // Don't clear section vm because we want to keep the expand/collapse state even during space changes. private readonly roomSectionHeaderViewModels = new Map(); + /** + * 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(); /** * 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 => { + 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; diff --git a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts index b1fe1a2fb8..bcd9ab94f2 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/section-test.ts @@ -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 = {}, + ): 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; + 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); diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 18fef44e8e..e0c3130970 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -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; diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index aea7c94ea8..3c312d1966 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -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", + ]); + }); + }); }); }); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..5346ad54c1 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx index 9f6843ffc2..9b107785b8 100644 --- a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx +++ b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx @@ -413,8 +413,16 @@ export function useVirtualizedList( [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, diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 9bf59cc09b..b2fba87061 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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", diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx index 76fbc4f67f..144f9c135c 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -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 ; }; @@ -105,6 +111,9 @@ const meta = { toast: undefined, closeToast: fn(), changeRoomSection: fn(), + changeSectionOrder: fn(), + onSectionDragStart: fn(), + onSectionDragEnd: fn(), }, parameters: { design: { diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx index 5d84e05d05..91d034d3ed 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.test.tsx @@ -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, +); + const { Default, Loading, diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index b0f1159794..da88149e9e 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -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; } /** diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 19386bd10d..30dd45f387 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -8428,42 +8428,47 @@ exports[` > renders LargeSectionList story 1`] = ` aria-setsize="23" role="row" > - + +
> renders LargeSectionList story 1`] = ` role="gridcell" >
- + +
> renders LargeSectionList story 1`] = ` role="gridcell" >
- + +
> renders SmallSectionList story 1`] = ` role="gridcell" >
- + + diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.test.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.test.ts new file mode 100644 index 0000000000..70057f1642 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.test.ts @@ -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 }; + 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(); + + 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, + options?: RoomListAccessibilityOptions, +): RoomListAccessibilityPlugin { + // RoomListAccessibilityPlugin extends Plugin 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("[role='status'][aria-live='polite']"); +} + +function getAssertiveRegion(): HTMLElement | null { + return document.querySelector("[role='alert'][aria-live='assertive']"); +} + +function getInstructions(): HTMLElement | null { + return document.querySelector("[style*='display: none']"); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RoomListAccessibilityPlugin", () => { + let manager: ReturnType; + + 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 | undefined; + manager.monitor.addEventListener.mockImplementation((_eventName: string, _handler: EventHandler) => { + unsubscribeSpy = vi.fn(); + return unsubscribeSpy as unknown as ReturnType void>>; + }); + + const plugin = createPlugin(manager, { + announcements: { dragstart: () => "Dragging" }, + }); + + plugin.destroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + }); + + describe("useRoomListAccessibilityPlugin announcements", () => { + const SECTION_TITLES: Record = { + work: "Work", + fun: "Fun", + }; + const ROOM_NAMES: Record = { + "!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> { + 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"); + }); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.ts new file mode 100644 index 0000000000..8abc896786 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.ts @@ -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 | null; + target: Droppable | 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>; + /** + * 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 ` + + + ); }); - -interface MenuComponentProps { - vm: RoomListSectionHeaderViewModel; -} - -/** - * - * Menu component for the section header. - */ - -function MenuComponent({ vm }: MenuComponentProps): JSX.Element { - const [open, setOpen] = useState(false); - - return ( - - - - } - > - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
e.stopPropagation()} - > - vm.editSection()} - onClick={(evt) => evt.stopPropagation()} - /> - vm.removeSection()} - onClick={(evt) => evt.stopPropagation()} - /> -
-
- ); -} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap index 31959771b0..7b83023b0f 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/__snapshots__/RoomListSectionHeaderView.test.tsx.snap @@ -14,75 +14,79 @@ exports[` stories > renders Default story 1`] = ` aria-setsize="5" role="row" > - - - + + + + diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts index 29d15c98bb..5668037aaa 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/index.ts @@ -6,6 +6,8 @@ */ export { RoomListSectionHeaderView } from "./RoomListSectionHeaderView"; +export { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent"; +export type { RoomListSectionHeaderContentProps } from "./RoomListSectionHeaderContent"; export type { RoomListSectionHeaderViewModel, RoomListSectionHeaderViewSnapshot, diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx index dcad99cde7..aa2e76e8e6 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.stories.tsx @@ -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: { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx index 902ce4cb4b..d1a2fc6f53 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -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("", () => { 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(); @@ -86,8 +91,8 @@ describe("", () => { 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("", () => { 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 + //