From 1f83ba4bbbbe9cc49461e0ec1ee58600b8bd0c21 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 26 Jun 2026 12:00:44 +0200 Subject: [PATCH] Room list: add drag and drop of sections to reorder them (#33606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../room-list-custom-sections.spec.ts | 37 +- .../e2e/left-panel/room-list-panel/utils.ts | 58 ++- apps/web/src/settings/Settings.tsx | 4 +- .../stores/room-list-v3/RoomListStoreV3.ts | 26 +- apps/web/src/stores/room-list-v3/section.ts | 75 ++- .../RoomListSectionHeaderViewModel.ts | 8 +- .../viewmodels/room-list/RoomListViewModel.ts | 32 ++ .../stores/room-list-v3/section-test.ts | 119 ++++- .../RoomListSectionHeaderViewModel-test.ts | 17 + .../room-list/RoomListViewModel-test.tsx | 102 ++++ .../default-auto.png | Bin 0 -> 18242 bytes .../core/VirtualizedList/virtualized-list.tsx | 8 + .../src/i18n/strings/en_EN.json | 13 +- .../RoomListView/RoomListView.stories.tsx | 9 + .../RoomListView/RoomListView.test.tsx | 8 +- .../room-list/RoomListView/RoomListView.tsx | 6 + .../__snapshots__/RoomListView.test.tsx.snap | 281 ++++++----- .../RoomListAccessibilityPlugin.test.ts | 437 ++++++++++++++++++ .../RoomListAccessibilityPlugin.ts | 276 +++++++++++ .../RoomListItemDragOverlayView.tsx | 4 + .../RoomListItemWrapper.tsx | 4 +- ...istSectionHeaderDragOverlayView.module.css | 11 + ...stSectionHeaderDragOverlayView.stories.tsx | 63 +++ ...mListSectionHeaderDragOverlayView.test.tsx | 22 + .../RoomListSectionHeaderDragOverlayView.tsx | 41 ++ ...SectionHeaderDragOverlayView.test.tsx.snap | 42 ++ .../index.ts | 9 + .../RoomListSectionHeaderContent.tsx | 117 +++++ .../RoomListSectionHeaderView.module.css | 31 +- .../RoomListSectionHeaderView.stories.tsx | 17 +- .../RoomListSectionHeaderView.test.tsx | 4 +- .../RoomListSectionHeaderView.tsx | 235 +++++----- .../RoomListSectionHeaderView.test.tsx.snap | 110 ++--- .../RoomListSectionHeaderView/index.ts | 2 + .../VirtualizedRoomListView.stories.tsx | 9 + .../VirtualizedRoomListView.test.tsx | 92 +++- .../VirtualizedRoomListView.tsx | 53 ++- .../VirtualizedRoomListView/dragAndDrop.ts | 20 + .../src/room-list/story-mocks.tsx | 3 + packages/shared-components/vitest.config.ts | 1 + 40 files changed, 2064 insertions(+), 342 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.test.ts create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListAccessibilityPlugin.ts create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.module.css create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.test.tsx create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.tsx create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/__snapshots__/RoomListSectionHeaderDragOverlayView.test.tsx.snap create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/index.ts create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderContent.tsx create mode 100644 packages/shared-components/src/room-list/VirtualizedRoomListView/dragAndDrop.ts 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 0000000000000000000000000000000000000000..5346ad54c1ea611660446a859f8c7c3d7180a914 GIT binary patch literal 18242 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 gy%5;b=q*hRd1!rF( [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 + //