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 356c129e724..0a7c1c4caec 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 { 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({ @@ -284,6 +292,21 @@ 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.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/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index 14256cf6456..62b2de73aab 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -8,7 +8,15 @@ import { rejectToast } from "@element-hq/element-web-playwright-common"; import { expect, test } from "../../../element-web-test"; -import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; +import { + assertRoomInSection, + assertSectionsOrder, + dragRoomToSection, + dragSectionToSection, + getPrimaryFilters, + getRoomList, + getSectionHeader, +} from "./utils"; test.describe("Room list sections", () => { test.use({ @@ -197,6 +205,29 @@ test.describe("Room list sections", () => { await assertRoomInSection(page, "Favourites", "my room"); }); + test("should reorder default sections via dnd", async ({ page, app }) => { + // Populate each default section so all three headers are visible + const favouriteId = await app.client.createRoom({ name: "fav room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + await app.client.createRoom({ name: "regular room" }); + + const lowPrioId = await app.client.createRoom({ name: "low prio room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.lowpriority"); + }, lowPrioId); + + // Initial order + await assertSectionsOrder(page, ["Favourites", "Chats", "Low Priority"]); + + // Moves Favourites to immediately after Low Priority + await dragSectionToSection(page, "Favourites", "Low Priority"); + + await assertSectionsOrder(page, ["Chats", "Low Priority", "Favourites"]); + }); + test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => { const favouriteId = await app.client.createRoom({ name: "my room" }); await app.client.evaluate(async (client, roomId) => { 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 fbf2643ebf3..b934810bd3c 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 @@ -78,6 +78,59 @@ export async function dragRoomToSection(page: Page, roomName: string, sectionNam await page.mouse.up(); } +/** + * Drag and drop a section header onto another section header. The dragged section is moved + * to the position immediately after the target section. 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 4ca3bf731e0..e85e6f3f146 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 CustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts"; import { type NotificationSound } from "../Notifier.ts"; export const defaultWatchManager = new WatchManager(); @@ -368,7 +368,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 7004efc0903..dc8ff79880d 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -36,11 +36,10 @@ 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, getOrderedSections, reorderSection } from "./section"; /** * These are the filters passed to the room skip list. @@ -511,6 +510,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. */ @@ -522,8 +530,7 @@ 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]; + this.sortedTags = getOrderedSections(); } } diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 1cde7e6a042..3992925937c 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -83,9 +83,14 @@ function isValidCustomSection(value: unknown): value is CustomSection { */ export type CustomSectionsData = Record; /** - * Ordered list of custom section tags. + * Union of all valid section tags (default + custom). */ -export type OrderedCustomSections = CustomTag[]; +export type SectionTag = CustomTag | DefaultTagID.Favourite | DefaultTagID.LowPriority | typeof CHATS_TAG; + +/** + * Ordered list of section tags (default + custom). + */ +export type CustomSections = SectionTag[]; /** * Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room. @@ -122,13 +127,51 @@ 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. */ -export function getOrderedCustomSections(): OrderedCustomSections { +export function getOrderedCustomSections(): CustomSections { const sectionData = getCustomSectionData(); const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections"); - const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : []; + const orderedSections: CustomSections = Array.isArray(rawValue) ? rawValue : []; return orderedSections.filter((tag) => tag in sectionData); } +const DEFAULT_SECTION_TAGS = new Set([DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]); + +/** + * Returns the full ordered list of all sections (default + custom). + * If the stored order includes all three default tags, it is used as-is (minus deleted custom sections), + * with any new custom sections inserted before LowPriority. + * Falls back to the canonical order when the stored value is empty or contains no default tags. + */ +export function getOrderedSections(): SectionTag[] { + const customData = getCustomSectionData(); + const availableCustomTags = Object.keys(customData).filter(isCustomSectionTag) as CustomTag[]; + + const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections"); + const tags: SectionTag[] = Array.isArray(rawValue) ? (rawValue as SectionTag[]) : []; + + // Keep only valid tags (remove deleted custom sections, keep all defaults) + const result = tags.filter((t) => DEFAULT_SECTION_TAGS.has(t) || (isCustomSectionTag(t) && t in customData)); + + // Ensure all 3 default tags are present + for (const tag of [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority] as SectionTag[]) { + if (!result.includes(tag)) result.push(tag); + } + + // Append any new custom tags not yet in the list, before LowPriority + for (const tag of availableCustomTags) { + if (!result.includes(tag)) { + const lpIndex = result.indexOf(DefaultTagID.LowPriority); + if (lpIndex === -1) { + result.push(tag); + } else { + result.splice(lpIndex, 0, tag); + } + } + } + + return result; +} + /** * Creates a new custom section by showing a dialog to the user to enter the section name. * If the user confirms, it generates a unique tag for the section, saves the section data in the settings, and updates the ordered list of sections. @@ -212,3 +255,21 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise { + const ordered = getOrderedSections(); + const fromIndex = ordered.indexOf(sourceTag as SectionTag); + + if (fromIndex === -1 || !ordered.includes(targetTag as SectionTag) || sourceTag === targetTag) return; + + ordered.splice(fromIndex, 1); + const newToIndex = ordered.indexOf(targetTag as SectionTag); + ordered.splice(newToIndex + 1, 0, sourceTag as SectionTag); + await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered); +} diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index c988e9e6687..25c23824de4 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. @@ -673,6 +678,28 @@ export class RoomListViewModel }, 15 * 1000); } + public changeSectionOrder = (sourceTag: string, targetTag: string): void => { + RoomListStoreV3.instance.reorderSection(sourceTag, targetTag); + }; + + 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 b1fe1a2fb87..0c13c71794d 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"; @@ -335,6 +336,87 @@ describe("section", () => { }); }); + describe("reorderSection", () => { + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc`; + + 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 default section after another default", + initial: [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority], + customData: {}, + source: DefaultTagID.Favourite, + target: CHATS_TAG, + expected: [CHATS_TAG, DefaultTagID.Favourite, DefaultTagID.LowPriority], + }, + { + description: "a custom section after a default", + initial: [DefaultTagID.Favourite, customTag, CHATS_TAG, DefaultTagID.LowPriority], + customData: { [customTag]: { tag: customTag, name: "Custom" } }, + source: customTag, + target: DefaultTagID.LowPriority, + expected: [DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority, customTag], + }, + ])( + "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: CHATS_TAG, + target: CHATS_TAG, + }, + { + description: "source is not in the ordered list", + source: `${CUSTOM_SECTION_TAG_PREFIX}unknown`, + target: CHATS_TAG, + }, + { + description: "target is not in the ordered list", + source: DefaultTagID.Favourite, + target: `${CUSTOM_SECTION_TAG_PREFIX}unknown`, + }, + ])("does nothing when $description", async ({ source, target }) => { + mockSettings([DefaultTagID.Favourite, CHATS_TAG, DefaultTagID.LowPriority]); + 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/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index 026fd4c74c0..74a53f8688d 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -1185,6 +1185,101 @@ 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", () => { + const reorderSpy = jest + .spyOn(RoomListStoreV3.instance, "reorderSection") + .mockResolvedValue(undefined); + + viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG); + + expect(reorderSpy).toHaveBeenCalledWith(DefaultTagID.Favourite, CHATS_TAG); + }); + + 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 00000000000..72a88090915 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/room-list/RoomListView/RoomListView.stories.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx index 76fbc4f67f2..144f9c135cc 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.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 0a3b5d21135..16365bcd977 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -76,6 +76,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 c9079bc34bc..b74f1a30502 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 @@ -8369,42 +8369,46 @@ exports[` > renders LargeSectionList story 1`] = ` aria-setsize="23" role="row" > - + +
> renders LargeSectionList story 1`] = ` aria-setsize="29" role="row" > -
- + +
> renders SmallSectionList story 1`] = ` aria-setsize="2" role="row" > -
- + +
> renders SmallSectionList story 1`] = ` aria-setsize="0" role="row" > -
- + + diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.module.css new file mode 100644 index 00000000000..b4fb0d2c020 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.module.css @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.dragOverlay { + padding-top: 0px; + padding-bottom: 0px; +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx new file mode 100644 index 00000000000..4a347e1705a --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.stories.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + type RoomListSectionHeaderViewSnapshot, + type RoomListSectionHeaderActions, +} from "../RoomListSectionHeaderView"; +import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView"; +import { useMockedViewModel } from "../../../core/viewmodel"; +import { withViewDocs } from "../../../../.storybook/withViewDocs"; + +type RoomListSectionHeaderDragOverlayProps = RoomListSectionHeaderViewSnapshot & RoomListSectionHeaderActions; + +const RoomListSectionHeaderDragOverlayWrapperImpl = ({ + onClick, + editSection, + removeSection, + ...rest +}: RoomListSectionHeaderDragOverlayProps): JSX.Element => { + const vm = useMockedViewModel(rest, { onClick, editSection, removeSection }); + return ; +}; +const RoomListSectionHeaderDragOverlayWrapper = withViewDocs( + RoomListSectionHeaderDragOverlayWrapperImpl, + RoomListSectionHeaderDragOverlayView, +); + +const meta = { + title: "Room List/RoomListSectionHeaderDragOverlayView", + component: RoomListSectionHeaderDragOverlayWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + id: "element.io.section.abc123", + title: "Work", + isExpanded: true, + isUnread: false, + displaySectionMenu: true, + onClick: fn(), + editSection: fn(), + removeSection: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.test.tsx new file mode 100644 index 00000000000..c5bd2fec587 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { render } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomListSectionHeaderDragOverlayView.stories"; + +const { Default } = composeStories(stories); + +describe(" stories", () => { + it("renders Default story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.tsx new file mode 100644 index 00000000000..93454cb91ea --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/RoomListSectionHeaderDragOverlayView.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { memo, type JSX } from "react"; +import classNames from "classnames"; + +import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView"; +import { RoomListSectionHeaderContent } from "../RoomListSectionHeaderView/RoomListSectionHeaderContent"; +import headerStyles from "../RoomListSectionHeaderView/RoomListSectionHeaderView.module.css"; +import styles from "./RoomListSectionHeaderDragOverlayView.module.css"; + +/** + * Props for {@link RoomListSectionHeaderDragOverlayView}. + */ +export interface RoomListSectionHeaderDragOverlayViewProps { + /** The section header view model — same one used by the real section header */ + vm: RoomListSectionHeaderViewModel; +} + +/** + * Visual clone of a section header rendered inside the dnd drag overlay. + * + * Reuses {@link RoomListSectionHeaderContent} for the inner layout so the + * floating clone matches a real section header. + */ +export const RoomListSectionHeaderDragOverlayView = memo(function RoomListSectionHeaderDragOverlayView({ + vm, +}: RoomListSectionHeaderDragOverlayViewProps): JSX.Element { + return ( +
+ +
+ ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/__snapshots__/RoomListSectionHeaderDragOverlayView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/__snapshots__/RoomListSectionHeaderDragOverlayView.test.tsx.snap new file mode 100644 index 00000000000..b6932efc2ec --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/__snapshots__/RoomListSectionHeaderDragOverlayView.test.tsx.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` stories > renders Default story 1`] = ` +
+
+
+
+
+ + + + + Work + +
+
+
+
+
+`; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/index.ts new file mode 100644 index 00000000000..c692f3e3f5d --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderDragOverlayView/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView"; +export type { RoomListSectionHeaderDragOverlayViewProps } from "./RoomListSectionHeaderDragOverlayView"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderContent.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderContent.tsx new file mode 100644 index 00000000000..fb0ebd08ac9 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderContent.tsx @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { memo, type JSX, useState } from "react"; +import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right"; +import classNames from "classnames"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { useViewModel } from "../../../core/viewmodel"; +import { _t } from "../../../core/i18n/i18n"; +import { Flex } from "../../../core/utils/Flex"; +import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView"; +import styles from "./RoomListSectionHeaderView.module.css"; + +/** + * Props for {@link RoomListSectionHeaderContent}. + */ +export interface RoomListSectionHeaderContentProps { + /** The section header view model */ + vm: RoomListSectionHeaderViewModel; + /** Whether the section header is being dragged — hides the interactive menu when true */ + isDragging?: boolean; + /** Whether the section header is a drop target */ + isDropTarget?: boolean; + /** Whether the section header is a drop target for another section */ + isDraggingSection?: boolean; +} + +/** + * The inner content of a section header: chevron, title, and menu (or static menu icon when dragging). + * Used both inside the full {@link RoomListSectionHeaderView} and inside the drag overlay. + */ +export const RoomListSectionHeaderContent = memo(function RoomListSectionHeaderContent({ + vm, + isDragging = false, + isDropTarget = false, + isDraggingSection = false, +}: RoomListSectionHeaderContentProps): JSX.Element { + const { title, displaySectionMenu } = useViewModel(vm); + return ( + + + + {title} + + {displaySectionMenu && !isDragging && } + + ); +}); + +interface MenuComponentProps { + vm: RoomListSectionHeaderViewModel; +} + +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/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css index 6c224675804..da46a23e271 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css @@ -60,6 +60,7 @@ } .container { + position: relative; margin: 0 var(--cpd-space-3x); padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x); border-radius: 8px; @@ -88,10 +89,25 @@ padding-bottom: 0; } -.dropTarget { +.border { box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary); } +.borderBottom::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: var(--cpd-color-border-accent-primary); +} + .menu { display: none; } + +.dragging { + outline: 1px solid var(--cpd-color-border-interactive-hovered); + background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 90%, transparent); +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.test.tsx index 6ab7bcd236f..7013fe5b1ae 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.test.tsx @@ -25,7 +25,7 @@ describe(" stories", () => { const user = userEvent.setup(); const { getByRole } = render(); - const button = getByRole("gridcell", { name: "Toggle Favourites section" }); + const button = getByRole("button", { name: "Toggle Favourites section" }); await user.click(button); expect(Default.args.onClick).toHaveBeenCalled(); }); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx index 115f112d103..366f6f510a0 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx @@ -5,19 +5,18 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { memo, type JSX, type FocusEvent, type MouseEventHandler, useState } from "react"; -import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right"; +import React, { memo, type JSX, type FocusEvent, type MouseEventHandler } from "react"; import classNames from "classnames"; -import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; -import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { useDroppable } from "@dnd-kit/react"; +import { useDraggable, useDragOperation, useDroppable } from "@dnd-kit/react"; +import { Feedback } from "@dnd-kit/dom"; +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"; +import { useMergeRefs } from "react-merge-refs"; import { useViewModel, type ViewModel } from "../../../core/viewmodel"; import styles from "./RoomListSectionHeaderView.module.css"; -import { Flex } from "../../../core/utils/Flex"; import { useI18n } from "../../../core/i18n/i18nContext"; import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList"; -import { _t } from "../../../core/i18n/i18n"; +import { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent"; /** * The observable state snapshot for a room list section header. @@ -101,114 +100,52 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView roomCountInSection, }: Readonly): JSX.Element { const { translate: _t } = useI18n(); - const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); + const { id, title, isExpanded, isUnread } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; - const { ref, isDropTarget } = useDroppable({ + const { ref: draggableRef, handleRef } = useDraggable({ id, + data: { type: "section" }, + plugins: [Feedback.configure({ feedback: "clone" })], + modifiers: [RestrictToVerticalAxis], }); + const { ref: droppableRef, isDropTarget } = useDroppable({ id }); + const { source } = useDragOperation(); + const isDraggingSection = (source?.data as { type?: string })?.type === "section"; + const buttonRef = useMergeRefs([draggableRef, handleRef, droppableRef]) as React.Ref; return (
- + + +
); }); - -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 31959771b04..7b83023b0f9 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 29d15c98bbd..5668037aaa5 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 dcad99cde7c..aa2e76e8e6f 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 902ce4cb4b4..4f1116bc893 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.test.tsx @@ -69,8 +69,11 @@ 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 () => { @@ -96,6 +99,31 @@ describe("", () => { expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority"); }); }); + + it("should fire section drag callbacks when reordering sections via keyboard", async () => { + // KeyboardSensor: Space=start, ArrowDown moves drag position 10px/press, Space=drop. + // Starting from the "Favourites" section header and pressing ArrowDown 20 times moves + // far enough down to land on the "low-priority" section header — a valid section reorder. + const user = userEvent.setup(); + renderWithMockContext(); + + const favouritesHeader = await screen.findByLabelText("Toggle Favourites section"); + favouritesHeader.focus(); + + await user.keyboard(" "); // start drag + + for (let i = 0; i < 20; i++) { + await user.keyboard("{ArrowDown}"); + } + + await user.keyboard(" "); // drop + + await waitFor(() => { + expect(Sections.args.onSectionDragStart).toHaveBeenCalled(); + expect(Sections.args.changeSectionOrder).toHaveBeenCalledWith("favourites", "low-priority"); + expect(Sections.args.onSectionDragEnd).toHaveBeenCalled(); + }); + }); }); describe("scrollToSectionTag", () => { diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx index 25499840901..b3e0f43c635 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/VirtualizedRoomListView.tsx @@ -22,6 +22,7 @@ import { import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView"; import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList"; import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView"; +import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView"; import { RoomListItemWrapper } from "./RoomListItemWrapper"; import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView"; import styles from "./VirtualizedRoomListView.module.css"; @@ -389,12 +390,23 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual return ( { + const { source } = event.operation; + if ((source?.data as { type?: string })?.type === "section") { + vm.onSectionDragStart(); + } + }} onDragEnd={(event) => { - if (event.canceled) return; - const { target, source } = event.operation; - if (!source || !target) return; - - vm.changeRoomSection(source.id as string, target.id as string); + const { source, target } = event.operation; + if ((source?.data as { type?: string })?.type === "section") { + vm.onSectionDragEnd(); + } + if (event.canceled || !source || !target) return; + if ((source.data as { type?: string })?.type === "section") { + vm.changeSectionOrder(source.id as string, target.id as string); + } else { + vm.changeRoomSection(source.id as string, target.id as string); + } }} sensors={[ // By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation. @@ -458,6 +470,11 @@ function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX. const { source } = useDragOperation(); if (!source) return null; + if ((source.data as { type?: string })?.type === "section") { + const sectionHeaderVM = vm.getSectionHeaderViewModel(source.id as string); + return ; + } + const itemVm = vm.getRoomItemViewModel(source.id as string); if (!itemVm) return null;