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 e40b1a6c0d..59cb322021 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 @@ -8,7 +8,7 @@ import { type Page } from "@playwright/test"; import { expect, test } from "../../../element-web-test"; -import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; +import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils"; test.describe("Room list custom sections", () => { test.use({ @@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => { await expect(dialog).not.toBeVisible(); } - /** - * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. - * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. - * Verifies that the closest preceding aria-level=1 row is the expected section header. - */ - async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { - const roomList = getRoomList(page); - const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); - // Room row must be at aria-level=2 (i.e. inside a section) - await expect(roomRow).toHaveAttribute("aria-level", "2"); - // The closest preceding aria-level=1 row must be the expected section header. - // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. - const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); - await expect(closestSectionHeader).toContainText(sectionName); - } - test.beforeEach(async ({ page, app, user }) => { // The notification toast is displayed above the search section await app.closeNotificationToast(); 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 d8c12f55da..ad2a9dd848 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 @@ -6,7 +6,7 @@ */ import { expect, test } from "../../../element-web-test"; -import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; +import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils"; test.describe("Room list sections", () => { test.use({ @@ -182,6 +182,37 @@ test.describe("Room list sections", () => { roomItem = roomList.getByRole("row", { name: "Open room my room" }); await expect(roomItem).toBeVisible(); }); + + test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => { + await app.client.createRoom({ name: "my room" }); + + const favouriteId = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + await dragRoomToSection(page, "my room", "Favourites"); + await assertRoomInSection(page, "Favourites", "my room"); + }); + + 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) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId); + + // Create a second favourite room to ensure we stay in section mode (not flat list) + const favouriteId2 = await app.client.createRoom({ name: "favourite room" }); + await app.client.evaluate(async (client, roomId) => { + await client.setRoomTag(roomId, "m.favourite"); + }, favouriteId2); + + // Ensure the Chats section is visible by creating a room in it + await app.client.createRoom({ name: "room in chats" }); + + await dragRoomToSection(page, "my room", "Chats"); + await assertRoomInSection(page, "Chats", "my room"); + }); }); test("should show unread indicator on section header", async ({ page, app, bot }) => { 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 523b268c80..fbf2643ebf 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 @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { type Locator, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; /** * Get the room list @@ -35,6 +35,49 @@ export function getSectionHeader(page: Page, sectionName: string, isUnread = fal }); } +/** + * Asserts a room is nested under a specific section using the treegrid aria-level hierarchy. + * Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2. + * Verifies that the closest preceding aria-level=1 row is the expected section header. + */ +export async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise { + const roomList = getRoomList(page); + const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` }); + // Room row must be at aria-level=2 (i.e. inside a section) + await expect(roomRow).toHaveAttribute("aria-level", "2"); + // The closest preceding aria-level=1 row must be the expected section header. + // XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one. + const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`); + await expect(closestSectionHeader).toContainText(sectionName); +} + +/** + * Drag and drop a room row onto a section header + * @param page + * @param roomName + * @param sectionName + */ +export async function dragRoomToSection(page: Page, roomName: string, sectionName: string): Promise { + const sourceRow = getRoomList(page).getByRole("row", { name: `Open room ${roomName}` }); + const source = sourceRow.locator("button").first(); + const target = getSectionHeader(page, sectionName); + + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + + const sourceX = sourceBox.x + sourceBox.width / 2; + const sourceY = sourceBox.y + sourceBox.height / 2; + const targetY = targetBox.y + targetBox.height / 2; + + // Grab the room + await page.mouse.move(sourceX, sourceY); + await page.mouse.down(); + // Move the room on the section header + await page.mouse.move(sourceX, targetY, { steps: 10 }); + // Drop the room + await page.mouse.up(); +} + /** * Get the primary filters container * @param page diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index 89cf0d7f64..ed06bfd54a 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index d0025d1fed..3a3d125e36 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 74b18c088b..6e13920c3e 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/apps/web/src/audio/RecorderWorklet.ts b/apps/web/src/audio/RecorderWorklet.ts index 5d6ce32630..53c99d2b8e 100644 --- a/apps/web/src/audio/RecorderWorklet.ts +++ b/apps/web/src/audio/RecorderWorklet.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { percentageOf } from "@element-hq/web-shared-components"; +import { percentageOf } from "@element-hq/web-shared-components/numbers"; import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts"; diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 60b86ddc81..a4a64344b1 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -40,7 +40,7 @@ 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 { createSection, deleteSection, editSection } from "./section"; +import { CHATS_TAG, createSection, deleteSection, editSection } from "./section"; /** * These are the filters passed to the room skip list. @@ -86,12 +86,6 @@ export interface Section { rooms: Room[]; } -/** - * A synthetic tag used to represent the "Chats" section, which contains - * every room that does not belong to any other explicit tag section. - */ -export const CHATS_TAG = "chats"; - export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated; diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 5cd4f97169..1fceba2c67 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog"; import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog"; +import { DefaultTagID, type TagID } from "./skip-list/tag"; type Tag = string; +/** + * A synthetic tag used to represent the "Chats" section, which contains + * every room that does not belong to any other explicit tag section. + */ +export const CHATS_TAG = "chats"; + /** * Prefix for custom section tags. */ @@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean { return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX); } +/** + * Checks if a given tag is a default section tag. + * @param tagId - The tag to check. + * @returns True if the tag is a default section tag, false otherwise. + */ +export function isDefaultSectionTag(tagId: TagID): boolean { + return tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority || tagId === CHATS_TAG; +} + +/** + * Checks if a given tag is a section tag. + * @param tagId - The tag to check. + * @returns True if the tag is a section tag, false otherwise. + */ +export function isSectionTag(tagId: TagID): boolean { + return isCustomSectionTag(tagId) || isDefaultSectionTag(tagId); +} + /** * Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user. */ diff --git a/apps/web/src/utils/arrays.ts b/apps/web/src/utils/arrays.ts index 63b16a6f72..3056738d3c 100644 --- a/apps/web/src/utils/arrays.ts +++ b/apps/web/src/utils/arrays.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { percentageOf, percentageWithin } from "@element-hq/web-shared-components"; +import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers"; /** * Quickly resample an array to have less/more data points. If an input which is larger diff --git a/apps/web/src/utils/room/getSectionTagForRoom.ts b/apps/web/src/utils/room/getSectionTagForRoom.ts new file mode 100644 index 0000000000..9dd59fd646 --- /dev/null +++ b/apps/web/src/utils/room/getSectionTagForRoom.ts @@ -0,0 +1,21 @@ +/* + * 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 { type Room } from "matrix-js-sdk/src/matrix"; + +import { type TagID } from "../../stores/room-list-v3/skip-list/tag"; +import { getTagsForRoom } from "./getTagsForRoom"; +import { isSectionTag } from "../../stores/room-list-v3/section"; + +/** + * Get the section tag for a given room. + * @param room The room to get the section tag for. + * @returns The section tag ID or null if none found. + */ +export function getSectionTagForRoom(room: Room): TagID | null { + return getTagsForRoom(room).find((t) => isSectionTag(t)) ?? null; +} diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index 62bac2ffca..4e0fb6625c 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details. import { type Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag"; +import { type TagID } from "../../stores/room-list-v3/skip-list/tag"; import RoomListActions from "../../actions/RoomListActions"; import dis from "../../dispatcher/dispatcher"; -import { getTagsForRoom } from "./getTagsForRoom"; -import { isCustomSectionTag } from "../../stores/room-list-v3/section"; +import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section"; +import { getSectionTagForRoom } from "./getSectionTagForRoom"; /** * Toggle tag for a given room. @@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section"; * @param tagId The tag to invert */ export function tagRoom(room: Room, tagId: TagID): void { - if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) { - logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); + const isChatTag = tagId === CHATS_TAG; + const tag = isChatTag ? null : tagId; + + if (!isSectionTag(tagId)) { + logger.warn(`Unexpected tag ${tag} applied to ${room.roomId}`); return; } // Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists - const currentSectionTag = - getTagsForRoom(room).find( - (t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t), - ) ?? null; + const currentSectionTag = getSectionTagForRoom(room); - const isApplied = currentSectionTag === tagId; + const isApplied = currentSectionTag === tag; const removeTag = currentSectionTag; - const addTag = isApplied ? null : tagId; + const addTag = isApplied ? null : tag; dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag)); } diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index b247ade51c..0959f962bc 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -38,8 +38,9 @@ import { Action } from "../../dispatcher/actions"; import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../PosthogTrackers"; import { type Call, CallEvent } from "../../models/Call"; -import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; import { _t } from "../../languageHandler"; +import { isDefaultSectionTag } from "../../stores/room-list-v3/section"; interface RoomItemProps { room: Room; @@ -429,9 +430,7 @@ export class RoomListItemViewModel RoomListStoreV3.instance.orderedSectionTags // Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section. // Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu. - .filter( - (tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority, - ) + .filter((tag) => !isDefaultSectionTag(tag)) .map((tag) => ({ tag, name: RoomListItemViewModel.getSectionName(tag, customSectionData), diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index a7b8d6dd2d..f20438cf9d 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; import SettingsStore from "../../settings/SettingsStore"; -import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; -import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; +import { isDefaultSectionTag } from "../../stores/room-list-v3/section"; interface RoomListSectionHeaderViewModelProps { tag: string; @@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel private readonly expandedBySpace = new Map(); public constructor(props: RoomListSectionHeaderViewModelProps) { - const isDefaultSection = - props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG; + const isDefaultSection = isDefaultSectionTag(props.tag); super(props, { id: props.tag, title: props.title, diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 6e28e61ed7..9ffaa3ebae 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -15,7 +15,7 @@ import { _t, type ToastType, } from "@element-hq/web-shared-components"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { Action } from "../../dispatcher/actions"; import dispatcher from "../../dispatcher/dispatcher"; @@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload" import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload"; import SpaceStore from "../../stores/spaces/SpaceStore"; import RoomListStoreV3, { - CHATS_TAG, RoomListStoreV3Event, type RoomsResult, type Section, @@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame"; import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag"; import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel"; import SettingsStore from "../../settings/SettingsStore"; +import { tagRoom } from "../../utils/room/tagRoom"; +import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom"; +import { CHATS_TAG } from "../../stores/room-list-v3/section"; /** * Tracks the position of the active room within a specific section. @@ -667,6 +669,17 @@ export class RoomListViewModel this.closeToast(); }, 15 * 1000); } + + public changeRoomSection = (roomId: string, tag: string): void => { + const room = this.props.client.getRoom(roomId); + if (!room) return; + + const currentTag = getSectionTagForRoom(room); + // Room is already in the section + if (currentTag === tag) return; + + tagRoom(room, tag); + }; } /** diff --git a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts index fa3def1e46..1b85a18751 100644 --- a/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts +++ b/apps/web/test/unit-tests/stores/room-list-v3/RoomListStoreV3-test.ts @@ -12,7 +12,6 @@ import { mocked } from "jest-mock"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState"; import { - CHATS_TAG, LISTS_UPDATE_EVENT, SECTION_CREATED_EVENT, RoomListStoreV3Class, @@ -37,6 +36,7 @@ import * as utils from "../../../../src/utils/notifications"; import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts"; import { Action } from "../../../../src/dispatcher/actions"; import { SettingLevel } from "../../../../src/settings/SettingLevel.ts"; +import { CHATS_TAG } from "../../../../src/stores/room-list-v3/section"; describe("RoomListStoreV3", () => { async function getRoomListStore() { 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 bfb999871e..2aa17e71bc 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 @@ -7,9 +7,18 @@ import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section"; +import { + CHATS_TAG, + CUSTOM_SECTION_TAG_PREFIX, + createSection, + editSection, + deleteSection, + isDefaultSectionTag, + isSectionTag, +} from "../../../../src/stores/room-list-v3/section"; import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog"; import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog"; +import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; describe("section", () => { afterEach(() => { @@ -203,4 +212,27 @@ describe("section", () => { expect(customDataCall![3]).not.toHaveProperty(tag); }); }); + + describe("isDefaultSectionTag", () => { + it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => { + expect(isDefaultSectionTag(tag)).toBe(true); + }); + + it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => { + expect(isDefaultSectionTag(tag)).toBe(false); + }); + }); + + describe("isSectionTag", () => { + it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG, `${CUSTOM_SECTION_TAG_PREFIX}some-uuid`])( + "returns true for %s", + (tag) => { + expect(isSectionTag(tag)).toBe(true); + }, + ); + + it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => { + expect(isSectionTag(tag)).toBe(false); + }); + }); }); diff --git a/apps/web/test/unit-tests/utils/room/getSectionTagForRoom-test.ts b/apps/web/test/unit-tests/utils/room/getSectionTagForRoom-test.ts new file mode 100644 index 0000000000..5c37a8f111 --- /dev/null +++ b/apps/web/test/unit-tests/utils/room/getSectionTagForRoom-test.ts @@ -0,0 +1,51 @@ +/* + * 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 { type Room } from "matrix-js-sdk/src/matrix"; + +import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; +import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; +import { getSectionTagForRoom } from "../../../../src/utils/room/getSectionTagForRoom"; +import { getTagsForRoom } from "../../../../src/utils/room/getTagsForRoom"; + +jest.mock("../../../../src/utils/room/getTagsForRoom"); + +const mockGetTagsForRoom = jest.mocked(getTagsForRoom); + +describe("getSectionTagForRoom", () => { + const room = {} as Room; + + it("should return null when room has no tags", () => { + mockGetTagsForRoom.mockReturnValue([]); + expect(getSectionTagForRoom(room)).toBeNull(); + }); + + it("should return null when room only has a non-section tag", () => { + mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged]); + expect(getSectionTagForRoom(room)).toBeNull(); + }); + + it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, `${CUSTOM_SECTION_TAG_PREFIX}abc-123`])( + "should return section tag %s when present", + (tag) => { + mockGetTagsForRoom.mockReturnValue([tag]); + expect(getSectionTagForRoom(room)).toBe(tag); + }, + ); + + it("should return the first section tag when multiple are present", () => { + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`; + mockGetTagsForRoom.mockReturnValue([DefaultTagID.Favourite, customTag]); + expect(getSectionTagForRoom(room)).toBe(DefaultTagID.Favourite); + }); + + it("should ignore non-section tags and return the section tag", () => { + const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`; + mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged, customTag]); + expect(getSectionTagForRoom(room)).toBe(customTag); + }); +}); diff --git a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts index cec9b805a2..4b29c7252e 100644 --- a/apps/web/test/unit-tests/utils/room/tagRoom-test.ts +++ b/apps/web/test/unit-tests/utils/room/tagRoom-test.ts @@ -11,23 +11,23 @@ import { Room } from "matrix-js-sdk/src/matrix"; import RoomListActions from "../../../../src/actions/RoomListActions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag"; -import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; +import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { getMockClientWithEventEmitter } from "../../../test-utils"; -import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom"; +import * as getSectionTagForRoomUtils from "../../../../src/utils/room/getSectionTagForRoom"; describe("tagRoom()", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`; - const makeRoom = (tags: TagID[] = []): Room => { + const makeRoom = (currentSectionTag: TagID | null = null): Room => { const client = getMockClientWithEventEmitter({ isGuest: jest.fn(), }); const room = new Room(roomId, client, userId); - jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags); + jest.spyOn(getSectionTagForRoomUtils, "getSectionTagForRoom").mockReturnValue(currentSectionTag); return room; }; @@ -51,7 +51,7 @@ describe("tagRoom()", () => { expect(RoomListActions.tagRoom).not.toHaveBeenCalled(); }); - describe("when a room has no tags", () => { + describe("when a room has no section tag", () => { it("should tag a room as favourite", () => { const room = makeRoom(); @@ -93,11 +93,25 @@ describe("tagRoom()", () => { customTag, // add ); }); + + it("should do nothing meaningful when applying CHATS_TAG", () => { + const room = makeRoom(); + + tagRoom(room, CHATS_TAG); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + null, // remove + null, // add + ); + }); }); describe("when a room is tagged as favourite", () => { it("should unfavourite a room", () => { - const room = makeRoom([DefaultTagID.Favourite]); + const room = makeRoom(DefaultTagID.Favourite); tagRoom(room, DefaultTagID.Favourite); @@ -111,7 +125,7 @@ describe("tagRoom()", () => { }); it("should tag a room low priority", () => { - const room = makeRoom([DefaultTagID.Favourite]); + const room = makeRoom(DefaultTagID.Favourite); tagRoom(room, DefaultTagID.LowPriority); @@ -123,10 +137,25 @@ describe("tagRoom()", () => { DefaultTagID.LowPriority, // add ); }); + + it("should remove the favourite tag when applying CHATS_TAG", () => { + const room = makeRoom(DefaultTagID.Favourite); + + tagRoom(room, CHATS_TAG); + + expect(defaultDispatcher.dispatch).toHaveBeenCalled(); + expect(RoomListActions.tagRoom).toHaveBeenCalledWith( + room.client, + room, + DefaultTagID.Favourite, // remove + null, // add + ); + }); }); + describe("when a room is tagged as low priority", () => { it("should favourite a room", () => { - const room = makeRoom([DefaultTagID.LowPriority]); + const room = makeRoom(DefaultTagID.LowPriority); tagRoom(room, DefaultTagID.Favourite); @@ -140,7 +169,7 @@ describe("tagRoom()", () => { }); it("should untag a room low priority", () => { - const room = makeRoom([DefaultTagID.LowPriority]); + const room = makeRoom(DefaultTagID.LowPriority); tagRoom(room, DefaultTagID.LowPriority); @@ -161,8 +190,9 @@ describe("tagRoom()", () => { { label: "untag the custom section", applyTag: customTag, expectedAdd: null }, { label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite }, { label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag }, + { label: "remove section tag when applying CHATS_TAG", applyTag: CHATS_TAG, expectedAdd: null }, ])("should $label", ({ applyTag, expectedAdd }) => { - const room = makeRoom([customTag]); + const room = makeRoom(customTag); tagRoom(room, applyTag); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index b545f316f9..3834803d07 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -30,8 +30,9 @@ import { Action } from "../../../src/dispatcher/actions"; import { CallStore } from "../../../src/stores/CallStore"; import { CallEvent, type Call } from "../../../src/models/Call"; import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel"; -import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; import * as tagRoomModule from "../../../src/utils/room/tagRoom"; +import { CHATS_TAG } from "../../../src/stores/room-list-v3/section"; jest.mock("../../../src/viewmodels/room-list/utils", () => ({ hasAccessToOptionsMenu: jest.fn().mockReturnValue(true), diff --git a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts index 000bbddd61..bd39d6a17b 100644 --- a/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts +++ b/apps/web/test/viewmodels/room-list/RoomListSectionHeaderViewModel-test.ts @@ -13,8 +13,9 @@ import { RoomNotificationStateStore } from "../../../src/stores/notifications/Ro import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; import { createTestClient, mkRoom } from "../../test-utils"; import SettingsStore from "../../../src/settings/SettingsStore"; -import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; +import { CHATS_TAG } from "../../../src/stores/room-list-v3/section"; describe("RoomListSectionHeaderViewModel", () => { let onToggleExpanded: jest.Mock; diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index c3fc431646..0631b94631 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -10,7 +10,7 @@ import { mocked } from "jest-mock"; import { waitFor } from "jest-matrix-react"; import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils"; -import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3"; import SpaceStore from "../../../src/stores/spaces/SpaceStore"; import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters"; import dispatcher from "../../../src/dispatcher/dispatcher"; @@ -21,6 +21,17 @@ import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListVie import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag"; import SettingsStore from "../../../src/settings/SettingsStore"; +import { tagRoom } from "../../../src/utils/room/tagRoom"; +import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom"; +import { CHATS_TAG } from "../../../src/stores/room-list-v3/section"; + +jest.mock("../../../src/utils/room/tagRoom", () => ({ + tagRoom: jest.fn(), +})); + +jest.mock("../../../src/utils/room/getSectionTagForRoom", () => ({ + getSectionTagForRoom: jest.fn().mockReturnValue(null), +})); jest.mock("../../../src/viewmodels/room-list/utils", () => ({ hasCreateRoomRights: jest.fn().mockReturnValue(false), @@ -1108,4 +1119,37 @@ describe("RoomListViewModel", () => { }); }); }); + + describe("changeRoomSection", () => { + beforeEach(() => { + viewModel = new RoomListViewModel({ client: matrixClient }); + mocked(tagRoom).mockClear(); + }); + + it("should call tagRoom with the room and target tag", () => { + jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1); + mocked(getSectionTagForRoom).mockReturnValue(null); + + viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite); + + expect(tagRoom).toHaveBeenCalledWith(room1, DefaultTagID.Favourite); + }); + + it("should do nothing when the room is not found", () => { + jest.spyOn(matrixClient, "getRoom").mockReturnValue(null); + + viewModel.changeRoomSection("!unknown:server", DefaultTagID.Favourite); + + expect(tagRoom).not.toHaveBeenCalled(); + }); + + it("should do nothing when the room is already in the target section", () => { + jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1); + mocked(getSectionTagForRoom).mockReturnValue(DefaultTagID.Favourite); + + viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite); + + expect(tagRoom).not.toHaveBeenCalled(); + }); + }); }); diff --git a/package.json b/package.json index 8e4b97cb3a..1db1dcb0f8 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "postcss-mixins": "patches/postcss-mixins.patch", "app-builder-lib": "patches/app-builder-lib.patch", "knip": "patches/knip.patch", - "plist": "patches/plist.patch" + "plist": "patches/plist.patch", + "@dnd-kit/abstract": "patches/@dnd-kit__abstract.patch" }, "peerDependencyRules": { "allowedVersions": { diff --git a/packages/shared-components/.storybook/preview.tsx b/packages/shared-components/.storybook/preview.tsx index e821a74c45..a6f3c3c34e 100644 --- a/packages/shared-components/.storybook/preview.tsx +++ b/packages/shared-components/.storybook/preview.tsx @@ -16,10 +16,12 @@ import "./app-web-root.css"; import "./preview.css"; import React, { useLayoutEffect } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; -import type { StoryContext } from "storybook/internal/csf"; import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src"; import { setLanguage } from "../src/core/i18n/i18n"; +import { StoryContext } from "storybook/internal/csf"; +import { DragDropProvider } from "@dnd-kit/react"; +import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom"; export const globalTypes = { theme: { @@ -172,7 +174,28 @@ const withEventPresentationProvider: Decorator = (Story, context) => { ); }; -const preview = { +/** + * Wrap all stories in a DragDropProvider that excludes the Accessibility plugin. + * dnd-kit's Accessibility plugin adds aria attributes (tabindex, aria-pressed, etc.) + * that conflict with the existing ARIA roles used in the room list components. + */ +const withDragDropProvider: Decorator = (Story) => { + return ( + + + + ); +}; + +const preview: Preview = { tags: ["autodocs", "snapshot"], initialGlobals: { rootCss: "storybook", @@ -181,7 +204,14 @@ const preview = { eventLayout: "group", eventDensity: "default", }, - decorators: [withRootCss, withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider], + decorators: [ + withRootCss, + withThemeProvider, + withEventPresentationProvider, + withTooltipProvider, + withI18nProvider, + withDragDropProvider, + ], parameters: { options: { storySort: { diff --git a/packages/shared-components/README.md b/packages/shared-components/README.md index dfe9805419..72f574edef 100644 --- a/packages/shared-components/README.md +++ b/packages/shared-components/README.md @@ -40,6 +40,21 @@ or in CSS file: @import url("@element-hq/web-shared-components"); ``` +### Sub-path Imports + +Callers running outside the browser DOM (e.g. inside an `AudioWorkletGlobalScope` +or a worker) can pull in the small standalone `numbers` utility bundle without +loading the rest of the package bundle, which transitively imports React, +dnd-kit, and other code that touches `window` / `document`: + +```javascript +import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers"; +``` + +The sub-path exposes the same functions listed under [Formatting](#formatting) +and ships as its own ES/CJS bundle in `dist/numbers.{js,umd.cjs}`. Prefer the +main package entry for everything else. + ### Using Components There are two kinds of components in this library: diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..c65679e47f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 13dda3ba76..eae853d214 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -21,6 +21,16 @@ "default": "./dist/element-web-shared-components.js" } }, + "./numbers": { + "require": { + "types": "./dist/numbers.d.ts", + "default": "./dist/numbers.umd.cjs" + }, + "import": { + "types": "./dist/numbers.d.ts", + "default": "./dist/numbers.js" + } + }, "./dist/element-web-shared-components.css": { "require": "./dist/element-web-shared-components.css", "import": "./dist/element-web-shared-components.css" @@ -51,6 +61,9 @@ "lint:types": "nx lint:types" }, "dependencies": { + "@dnd-kit/abstract": "^0.4.0", + "@dnd-kit/dom": "^0.4.0", + "@dnd-kit/react": "^0.4.0", "@element-hq/element-web-module-api": "workspace:*", "@matrix-org/spec": "^1.7.0", "@vector-im/compound-design-tokens": "catalog:", diff --git a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx index 0eb632f3f1..9f6843ffc2 100644 --- a/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx +++ b/packages/shared-components/src/core/VirtualizedList/virtualized-list.tsx @@ -81,6 +81,13 @@ export interface VirtualizedListProps extends Omit< */ onKeyDown?: (e: React.KeyboardEvent) => void; + /** + * When true, keyboard navigation (Arrow keys, Home, End, Page Up/Down) is disabled. + * All key events are forwarded directly to `onKeyDown` instead. + * Use this to prevent the list from scrolling while an item is being dragged via keyboard. + */ + disableKeyboardNavigation?: boolean; + /** * Optional total count of items (for virtualization with partial data loading). * If provided, this will be used instead of items.length for the total count. @@ -164,6 +171,7 @@ export function useVirtualizedList( getItemKey, context, onKeyDown, + disableKeyboardNavigation, totalCount, rangeChanged, mapScrollIndex, @@ -260,6 +268,13 @@ export function useVirtualizedList( return; } + // When keyboard navigation is disabled (e.g. during a keyboard drag), + // forward all events to the parent handler without handling navigation. + if (disableKeyboardNavigation) { + onKeyDown?.(e); + return; + } + if (e.code === Key.ARROW_UP && currentIndex !== undefined) { scrollToItem(currentIndex - 1, false); handled = true; @@ -300,7 +315,16 @@ export function useVirtualizedList( onKeyDown?.(e); } }, - [scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown], + [ + scrollToIndex, + scrollToItem, + tabIndexKey, + keyToIndexMap, + visibleRange, + items, + onKeyDown, + disableKeyboardNavigation, + ], ); /** 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 0917fcf72a..76fbc4f67f 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.stories.tsx @@ -40,6 +40,7 @@ const RoomListViewWrapperImpl = ({ updateVisibleRooms, renderAvatar: renderAvatarProp, closeToast, + changeRoomSection, ...rest }: RoomListViewProps): JSX.Element => { const vm = useMockedViewModel(rest, { @@ -50,6 +51,7 @@ const RoomListViewWrapperImpl = ({ getSectionHeaderViewModel, updateVisibleRooms, closeToast, + changeRoomSection, }); return ; }; @@ -102,6 +104,7 @@ const meta = { isFlatList: true, toast: undefined, closeToast: fn(), + changeRoomSection: 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 2a1f7d4b3d..0a3b5d2113 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -74,6 +74,8 @@ export interface RoomListViewActions { getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel; /** Called to close the toast message */ closeToast: () => void; + /** Called to change the section of a room */ + changeRoomSection: (roomId: string, tag: string) => 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 9534fa1080..8ead8b833e 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 @@ -8334,6 +8334,9 @@ exports[` > renders LargeSectionList story 1`] = ` class="Flex-module_flex RoomListView-module_list" style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" > +
> renders LargeSectionList story 1`] = ` aria-rowindex="1" role="row" > -
+
+ + +
+ -
- - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="2" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="3" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="4" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="5" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="6" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="7" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="8" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="9" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="10" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="11" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="12" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="13" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="14" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="15" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="16" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="17" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="18" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="19" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="20" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="21" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="22" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="23" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="24" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="25" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="26" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="27" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="28" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="29" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="30" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="31" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="32" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="33" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="34" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="35" role="row" > - - +
+
+ + +
+ - - - + + @@ -13663,6 +13771,9 @@ exports[` > renders SmallSectionList story 1`] = ` class="Flex-module_flex RoomListView-module_list" style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" > +
> renders SmallSectionList story 1`] = ` aria-rowindex="1" role="row" > -
+
+ + +
+ -
- - + +
> renders SmallSectionList story 1`] = ` aria-rowindex="2" role="row" > - - +
- + + + + + + + - - - + +
React.ReactElement; + }; + +const RoomListItemDragOverlayWrapperImpl = ({ + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + onCreateSection, + onToggleSection, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListItemDragOverlayProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + onCreateSection, + onToggleSection, + }); + return ; +}; +const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView); + +const meta = { + title: "Room List/RoomListItemDragOverlayView", + component: RoomListItemDragOverlayWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ...defaultSnapshot, + ...mockedActions, + renderAvatar, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx new file mode 100644 index 0000000000..863fed8b13 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx @@ -0,0 +1,23 @@ +/* + * 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, screen } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomListItemDragOverlayView.stories"; +import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot"; + +const { Default } = composeStories(stories); + +describe("", () => { + it("renders the room name from the view model", () => { + render(); + expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx new file mode 100644 index 0000000000..a5ff9727d9 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx @@ -0,0 +1,46 @@ +/* + * 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, memo, type ReactNode } from "react"; +import classNames from "classnames"; + +import { Flex } from "../../../core/utils/Flex"; +import { type Room, RoomListItemContent, type RoomListItemViewModel } from "../RoomListItemWrapper/RoomListItemView"; +import roomListItemStyles from "../RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css"; +import styles from "./RoomListItemDragOverlayView.module.css"; + +/** + * Props for {@link RoomListItemDragOverlayView}. + */ +export interface RoomListItemDragOverlayViewProps { + /** The room item view model — same one used by the real list item */ + vm: RoomListItemViewModel; + /** Function to render the room avatar */ + renderAvatar: (room: Room) => ReactNode; +} + +/** + * Visual clone of a room list item rendered inside the dnd drag overlay. + * + * Reuses {@link RoomListItemContent} for the inner layout and adds the outer + * wrapper styles that the live list item normally provides (height, width, + * typography), so the floating clone matches a real item. + */ +export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlayView({ + vm, + renderAvatar, +}: RoomListItemDragOverlayViewProps): JSX.Element { + return ( + + + + ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts new file mode 100644 index 0000000000..0e0b212f74 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/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 { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView"; +export type { RoomListItemDragOverlayViewProps } from "./RoomListItemDragOverlayView"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx new file mode 100644 index 0000000000..4843a3463f --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx @@ -0,0 +1,79 @@ +/* + * 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, memo, type ReactNode } from "react"; +import { Text } from "@vector-im/compound-web"; +import classNames from "classnames"; + +import { Flex } from "../../../../core/utils/Flex"; +import { useViewModel } from "../../../../core/viewmodel"; +import { NotificationDecoration } from "./NotificationDecoration"; +import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +import { type Room, type RoomListItemViewModel } from "./RoomListItemView"; +import styles from "./RoomListItemView.module.css"; + +/** + * Props for {@link RoomListItemContent}. + */ +export interface RoomListItemContentProps { + /** The room item view model */ + vm: RoomListItemViewModel; + /** Function to render the room avatar */ + renderAvatar: (room: Room) => ReactNode; + /** Whether the item is being dragged */ + isDragging?: boolean; +} + +/** + * The inner content of a room list item: avatar, room name, message preview, + * hover menu and notification decoration. Used both inside the full + * {@link RoomListItemView} and inside the drag overlay. + */ +export const RoomListItemContent = memo(function RoomListItemContent({ + vm, + renderAvatar, + isDragging = false, +}: RoomListItemContentProps): JSX.Element { + const item = useViewModel(vm); + + return ( + + {renderAvatar(item.room)} + + {/* We truncate the room name when too long. Title here is to show the full name on hover */} +
+
+ {item.name} +
+ {item.messagePreview && ( + + {item.messagePreview} + + )} +
+ {!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && ( + + )} + + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} +
+ +
+
+
+ ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css index 50b72d2429..fcb2e38ecc 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css @@ -70,6 +70,11 @@ min-width: 0; } +.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); +} + .content { flex: 1; min-width: 0; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx index 363a25feb0..b043857425 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx @@ -5,14 +5,14 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, memo, useEffect, useRef, type ReactNode } from "react"; +import React, { type JSX, memo, useEffect, useRef, type ReactNode, type Ref } from "react"; import classNames from "classnames"; -import { Text } from "@vector-im/compound-web"; +import { useMergeRefs } from "react-merge-refs"; import { Flex } from "../../../../core/utils/Flex"; -import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration"; -import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +import { type NotificationDecorationData } from "./NotificationDecoration"; import { RoomListItemContextMenu } from "./RoomListItemContextMenu"; +import { RoomListItemContent } from "./RoomListItemContent"; import { type RoomNotifState } from "./RoomNotifs"; import styles from "./RoomListItemView.module.css"; import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; @@ -150,6 +150,7 @@ export interface RoomListItemViewProps extends Omit ReactNode; + ref?: Ref; } /** @@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({ isFirstItem, isLastItem, renderAvatar, + ref, ...props }: RoomListItemViewProps): JSX.Element { - const ref = useRef(null); + const internalRef = useRef(null); + const mergedRef = useMergeRefs([ref, internalRef]); const item = useViewModel(vm); useEffect(() => { if (isFocused) { - ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions); + internalRef.current?.focus({ preventScroll: true } as FocusOptions); } }, [isFocused]); @@ -182,7 +185,7 @@ export const RoomListItemView = memo(function RoomListItemView({ ) => onFocus(item.id, e)} tabIndex={isFocused ? 0 : -1} + aria-selected={props.role === "option" ? isSelected : undefined} {...props} > - - {renderAvatar(item.room)} - - {/* We truncate the room name when too long. Title here is to show the full name on hover */} -
-
- {item.name} -
- {item.messagePreview && ( - - {item.messagePreview} - - )} -
- {(item.showMoreOptionsMenu || item.showNotificationMenu) && ( - - )} - - {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} -
- -
-
-
+
); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts index 72f2f98119..9d9b6595d6 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts @@ -14,6 +14,8 @@ export type { RoomListItemViewProps, Section, } from "./RoomListItemView"; +export { RoomListItemContent } from "./RoomListItemContent"; +export type { RoomListItemContentProps } from "./RoomListItemContent"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx index 1165e7a0b7..c19209d574 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx @@ -6,9 +6,14 @@ */ import React, { memo, type JSX } from "react"; +import { useDraggable } from "@dnd-kit/react"; +import { Feedback } from "@dnd-kit/dom"; +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"; +import { useMergeRefs } from "react-merge-refs"; import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView"; import { getItemAccessibleProps } from "../../../core/VirtualizedList"; +import { useViewModel } from "../../../core/viewmodel"; export interface RoomListItemWrapperProps extends RoomListItemViewProps { /** Index of this room in the list */ @@ -22,19 +27,8 @@ export interface RoomListItemWrapperProps extends RoomListItemViewProps { } /** - * Wrapper around RoomListItemView that adds accessibility props based on the room's position in the list and whether the list is flat or grouped. - * In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props. - * - * @example - * `` - * - * ``` + * Wraps RoomListItemView with the correct accessibility and drag-and-drop props + * based on whether the list is flat (listbox) or grouped (treegrid). */ export const RoomListItemWrapper = memo(function RoomListItemWrapper({ roomIndex, @@ -43,9 +37,30 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({ isInFlatList, ...rest }: RoomListItemWrapperProps): JSX.Element { - const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" }; - const item = ; + if (isInFlatList) { + return ; + } - if (isInFlatList) return item; - return
{item}
; + return ( +
+
+ +
+
+ ); }); + +/** + * Wraps RoomListItemView with the drag-and-drop functionality. This is only used for treegrid mode, as flat list items are not draggable. + */ +function DraggableWrapper(props: RoomListItemViewProps): JSX.Element { + const item = useViewModel(props.vm); + const { ref: draggableRef, handleRef } = useDraggable({ + id: item.id, + // We clone the item in the dnd overlay to avoid putting a hole in the list + plugins: [Feedback.configure({ feedback: "clone" })], + modifiers: [RestrictToVerticalAxis], + }); + const dndRef = useMergeRefs([draggableRef, handleRef]); + return ; +} 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 d587c8014f..9bc9982c30 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 @@ -87,6 +87,10 @@ padding-bottom: 0; } +.dropTarget { + box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary); +} + .menu { display: none; } 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 50b92c8112..115f112d10 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx @@ -10,6 +10,7 @@ import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons 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 { useViewModel, type ViewModel } from "../../../core/viewmodel"; import styles from "./RoomListSectionHeaderView.module.css"; @@ -103,12 +104,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; + const { ref, isDropTarget } = useDroppable({ + id, + }); + return (