Room list: drag and drop rooms into sections (#33366)
* chore: add dnd kit deps * chore: patch dnd kit to fix ts error * feat(sc): add drag-and-drop to room list item and wrapper * feat(sc): make the room list header a droppable element * feat(sc): add dnd to room list view * feat(tags): can tag room as CHAT * feat(vm): implement `changeRoomSection` * feat(sc): disable dragging in flat list * fix: disable keyboard navigation when dragging element * test(sc): update snapshots * test(sc): add dnd test * test(e2e): add e2e tests for room drag and drop * test(vm): add tests for changeRoomSection * fix: remove focus visible when dropping with the mouse * test(playwright): update existing screenshots * chore(sc): move numbers out of main build The Ew RecorderWorklet imports shared component bundle. However if the bundle uses some deps using document/window which, the worklet will not work. The solution is to put the used functions into a separate bundle. * doc(sc): add subpath import into README * doc: typo barrel/bundle * test: improve test expect * refactor: add utils to section tag * fix: incorrect check in tagRoom * fix: add doc about dndkit tunning
@@ -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<void> {
|
||||
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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
|
||||
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Story />
|
||||
</DragDropProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -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:",
|
||||
|
||||
@@ -81,6 +81,13 @@ export interface VirtualizedListProps<Item, Context> extends Omit<
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => 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<Item, Context>(
|
||||
getItemKey,
|
||||
context,
|
||||
onKeyDown,
|
||||
disableKeyboardNavigation,
|
||||
totalCount,
|
||||
rangeChanged,
|
||||
mapScrollIndex,
|
||||
@@ -260,6 +268,13 @@ export function useVirtualizedList<Item, Context>(
|
||||
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<Item, Context>(
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
},
|
||||
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
|
||||
[
|
||||
scrollToIndex,
|
||||
scrollToItem,
|
||||
tabIndexKey,
|
||||
keyToIndexMap,
|
||||
visibleRange,
|
||||
items,
|
||||
onKeyDown,
|
||||
disableKeyboardNavigation,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
@@ -102,6 +104,7 @@ const meta = {
|
||||
isFlatList: true,
|
||||
toast: undefined,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.dragOverlay {
|
||||
--padding-top: 0px;
|
||||
--padding-bottom: 0px;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
type Room,
|
||||
type RoomListItemViewActions,
|
||||
type RoomListItemViewSnapshot,
|
||||
} from "../RoomListItemWrapper/RoomListItemView";
|
||||
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
import { useMockedViewModel } from "../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../.storybook/withViewDocs";
|
||||
import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot";
|
||||
import { mockedActions } from "../RoomListItemWrapper/RoomListItemView/mocked-actions";
|
||||
import { renderAvatar } from "../../story-mocks";
|
||||
|
||||
type RoomListItemDragOverlayProps = RoomListItemViewSnapshot &
|
||||
RoomListItemViewActions & {
|
||||
renderAvatar: (room: Room) => 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 <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView);
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListItemDragOverlayView",
|
||||
component: RoomListItemDragOverlayWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px", padding: "8px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
...defaultSnapshot,
|
||||
...mockedActions,
|
||||
renderAvatar,
|
||||
},
|
||||
} satisfies Meta<typeof RoomListItemDragOverlayWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -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("<RoomListItemDragOverlayView />", () => {
|
||||
it("renders the room name from the view model", () => {
|
||||
render(<Default />);
|
||||
expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Flex
|
||||
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="stretch"
|
||||
>
|
||||
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} isDragging={true} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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 (
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dragging]: isDragging,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
>
|
||||
{renderAvatar(item.room)}
|
||||
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className={styles.ellipsis}>
|
||||
<div className={styles.roomName} title={item.name} data-testid="room-name">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.messagePreview && (
|
||||
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
|
||||
{item.messagePreview}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && (
|
||||
<RoomListItemHoverMenu
|
||||
showMoreOptionsMenu={item.showMoreOptionsMenu}
|
||||
showNotificationMenu={item.showNotificationMenu}
|
||||
vm={vm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...item.notification} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLBut
|
||||
isLastItem: boolean;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
ref?: Ref<Element>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
isFirstItem,
|
||||
isLastItem,
|
||||
renderAvatar,
|
||||
ref,
|
||||
...props
|
||||
}: RoomListItemViewProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const internalRef = useRef<HTMLButtonElement>(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({
|
||||
<RoomListItemContextMenu vm={vm}>
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
ref={mergedRef}
|
||||
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.bold]: item.isBold,
|
||||
@@ -193,41 +196,14 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="stretch"
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
aria-label={a11yLabel}
|
||||
onClick={vm.onOpenRoom}
|
||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-selected={props.role === "option" ? isSelected : undefined}
|
||||
{...props}
|
||||
>
|
||||
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
|
||||
{renderAvatar(item.room)}
|
||||
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className={styles.ellipsis}>
|
||||
<div className={styles.roomName} title={item.name} data-testid="room-name">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.messagePreview && (
|
||||
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
|
||||
{item.messagePreview}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
|
||||
<RoomListItemHoverMenu
|
||||
showMoreOptionsMenu={item.showMoreOptionsMenu}
|
||||
showNotificationMenu={item.showNotificationMenu}
|
||||
vm={vm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...item.notification} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} />
|
||||
</Flex>
|
||||
</RoomListItemContextMenu>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
* ``
|
||||
* <RoomListItemWrapper
|
||||
* roomIndex={0}
|
||||
* roomIndexInSection={0}
|
||||
* roomCount={10}
|
||||
* isInFlatList={true}
|
||||
* {...otherRoomListItemViewProps}
|
||||
* />
|
||||
* ```
|
||||
* 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 = <RoomListItemView {...rest} {...itemA11yProps} />;
|
||||
if (isInFlatList) {
|
||||
return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />;
|
||||
}
|
||||
|
||||
if (isInFlatList) return item;
|
||||
return <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>{item}</div>;
|
||||
return (
|
||||
<div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>
|
||||
<div role="gridcell" aria-selected={rest.isSelected}>
|
||||
<DraggableWrapper {...rest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 <RoomListItemView {...props} ref={dndRef} />;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-expanded={isExpanded}
|
||||
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
|
||||
>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={classNames(styles.header, {
|
||||
@@ -127,7 +133,14 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
: _t("room_list|section_header|toggle", { section: title })
|
||||
}
|
||||
>
|
||||
<Flex className={styles.container} align="center" justify="space-between" gap="var(--cpd-space-2x)">
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dropTarget]: isDropTarget,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<Flex align="center" gap="var(--cpd-space-0-5x)">
|
||||
<ChevronRightIcon
|
||||
className={styles.chevron}
|
||||
|
||||
@@ -36,6 +36,7 @@ const RoomListWrapperImpl = ({
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
renderAvatar: renderAvatarProp,
|
||||
changeRoomSection,
|
||||
...rest
|
||||
}: RoomListStoryProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
@@ -46,6 +47,7 @@ const RoomListWrapperImpl = ({
|
||||
getSectionHeaderViewModel,
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -85,6 +87,7 @@ const meta = {
|
||||
renderAvatar,
|
||||
isFlatList: true,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@test-utils";
|
||||
import { render, screen, fireEvent, waitFor } from "@test-utils";
|
||||
import { VirtuosoMockContext } from "react-virtuoso";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./VirtualizedRoomListView.stories";
|
||||
|
||||
@@ -65,6 +66,38 @@ describe("<VirtualizedRoomListView />", () => {
|
||||
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.
|
||||
(Sections.args.changeRoomSection as any).mockClear?.();
|
||||
});
|
||||
|
||||
it("should call changeRoomSection when drag ends successfully", async () => {
|
||||
// KeyboardSensor: Space=start, ArrowDown moves position 10px/press, Space=drop.
|
||||
// "General" (room 0) center is ~78px below the container top; "chats" section
|
||||
// header starts ~130px below that. 15 presses × 10px = 150px → drag position
|
||||
// enters the "chats" header area, making it the active droppable target.
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
const roomButton = await screen.findByRole("button", { name: "Open room General" });
|
||||
roomButton.focus();
|
||||
|
||||
await user.keyboard(" "); // start drag
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await user.keyboard("{ArrowDown}"); // move down 10px per press
|
||||
}
|
||||
|
||||
await user.keyboard(" "); // drop onto current target
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollToSectionTag", () => {
|
||||
it("skips scroll when scrollToSectionTag does not match any section", () => {
|
||||
const roomListState = {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
|
||||
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { isEqual } from "lodash";
|
||||
import { DragDropProvider, DragOverlay, useDragOperation } from "@dnd-kit/react";
|
||||
import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
|
||||
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
|
||||
import { useViewModel } from "../../core/viewmodel";
|
||||
@@ -18,9 +20,10 @@ import {
|
||||
type VirtualizedListContext,
|
||||
} from "../../core/VirtualizedList";
|
||||
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
|
||||
import { GroupedVirtualizedList } from "../../core/VirtualizedList";
|
||||
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
|
||||
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
||||
import { RoomListItemWrapper } from "./RoomListItemWrapper";
|
||||
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
import styles from "./VirtualizedRoomListView.module.css";
|
||||
|
||||
/**
|
||||
@@ -383,15 +386,78 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupedVirtualizedList<string, string, Context>
|
||||
{...commonProps}
|
||||
{...getContainerAccessibleProps("treegrid", totalCount)}
|
||||
scrollHandleRef={setVirtuosoHandle}
|
||||
groups={groups}
|
||||
getHeaderKey={getHeaderKey}
|
||||
getGroupHeaderComponent={getGroupHeaderComponent}
|
||||
getItemComponent={getItemComponentForGroupedList}
|
||||
isGroupHeaderFocusable={isGroupHeaderFocusable}
|
||||
/>
|
||||
<DragDropProvider
|
||||
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);
|
||||
}}
|
||||
sensors={[
|
||||
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
|
||||
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
|
||||
}),
|
||||
// By default, the KeyboardSensor uses both space and enter to start dragging, which interferes with the keyboard enter shortcut to open a room.
|
||||
KeyboardSensor.configure({
|
||||
keyboardCodes: {
|
||||
start: ["Space"],
|
||||
cancel: ["Escape"],
|
||||
end: ["Space"],
|
||||
up: ["ArrowUp"],
|
||||
down: ["ArrowDown"],
|
||||
left: ["ArrowLeft"],
|
||||
right: ["ArrowRight"],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
|
||||
</DragOverlay>
|
||||
<GroupedRoomList
|
||||
{...commonProps}
|
||||
{...getContainerAccessibleProps("treegrid", totalCount)}
|
||||
scrollHandleRef={setVirtuosoHandle}
|
||||
groups={groups}
|
||||
getHeaderKey={getHeaderKey}
|
||||
getGroupHeaderComponent={getGroupHeaderComponent}
|
||||
getItemComponent={getItemComponentForGroupedList}
|
||||
isGroupHeaderFocusable={isGroupHeaderFocusable}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component rendered inside DragDropProvider that renders the grouped virtualized list.
|
||||
* Uses useDragOperation to detect active keyboard drags and disable the list's own keyboard
|
||||
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
|
||||
*/
|
||||
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
|
||||
const { source } = useDragOperation();
|
||||
|
||||
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
|
||||
}
|
||||
|
||||
interface DragOverlayContentProps {
|
||||
/** The room list view model */
|
||||
vm: RoomListViewModel;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component rendered in the drag overlay when dragging a room item. Renders a copy of the dragged item to avoid dragging the actual element out of virtualization.
|
||||
*/
|
||||
function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX.Element | null {
|
||||
const { source } = useDragOperation();
|
||||
if (!source) return null;
|
||||
|
||||
const itemVm = vm.getRoomItemViewModel(source.id as string);
|
||||
if (!itemVm) return null;
|
||||
|
||||
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export { VirtualizedRoomListView } from "./VirtualizedRoomListView";
|
||||
export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";
|
||||
export * from "./RoomListSectionHeaderView";
|
||||
export * from "./RoomListItemWrapper";
|
||||
export * from "./RoomListItemDragOverlayView";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, esmExternalRequirePlugin, type Plugin } from "vite";
|
||||
@@ -16,23 +16,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cssLayerOrder = "@layer compound-tokens, compound-web, shared-components, app-web;";
|
||||
const sharedComponentsLayer = "shared-components";
|
||||
|
||||
const cssAssetFileName = "element-web-shared-components.css";
|
||||
|
||||
function layerCssAssets(): Plugin {
|
||||
return {
|
||||
name: "element-web-shared-components-css-layer",
|
||||
writeBundle(_options, bundle): void {
|
||||
for (const asset of Object.values(bundle)) {
|
||||
if (asset.type !== "asset" || asset.fileName !== "element-web-shared-components.css") {
|
||||
continue;
|
||||
}
|
||||
// Rename + layer-wrap the emitted CSS file. With multi-entry lib mode,
|
||||
// vite/rolldown derives CSS filenames from the unscoped package name (dropping
|
||||
// the `element-` prefix), so we rename on disk to keep the path stable for
|
||||
// consumers importing `@element-hq/web-shared-components/.../*.css`.
|
||||
writeBundle(options): void {
|
||||
const outDir = options.dir ?? resolve(__dirname, "dist");
|
||||
const expectedPath = resolve(outDir, cssAssetFileName);
|
||||
const renamedFromPath = resolve(outDir, "web-shared-components.css");
|
||||
|
||||
const cssPath = resolve(__dirname, "dist", asset.fileName);
|
||||
const source = readFileSync(cssPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(cssPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
if (existsSync(renamedFromPath)) {
|
||||
renameSync(renamedFromPath, expectedPath);
|
||||
}
|
||||
|
||||
// No CSS emitted in this build (e.g. storybook's vite build doesn't produce
|
||||
// the library CSS bundle), or already renamed and layered on a prior pass.
|
||||
if (!existsSync(expectedPath)) return;
|
||||
|
||||
const source = readFileSync(expectedPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) return;
|
||||
writeFileSync(expectedPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,10 +48,20 @@ function layerCssAssets(): Plugin {
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
// Two entries: the main bundle and a standalone `numbers` utility that callers
|
||||
// running outside the browser DOM (e.g. AudioWorkletGlobalScope) can import without
|
||||
// pulling in the rest of the package — which transitively loads dnd-kit and
|
||||
// other window/document-dependent code.
|
||||
entry: {
|
||||
"element-web-shared-components": resolve(__dirname, "src/index.ts"),
|
||||
"numbers": resolve(__dirname, "src/core/utils/numbers.ts"),
|
||||
},
|
||||
name: "Element Web Shared Components",
|
||||
// the proper extensions will be added
|
||||
fileName: "element-web-shared-components",
|
||||
// Multi-entry mode needs both formats explicit; UMD doesn't support multi-entry
|
||||
// (single global), so we ship ES + CJS and use the `.umd.cjs` extension for CJS
|
||||
// to keep the existing package.json `require` paths working.
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format, entryName) => `${entryName}.${format === "es" ? "js" : "umd.cjs"}`,
|
||||
},
|
||||
outDir: "dist",
|
||||
rolldownOptions: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
diff --git a/modifiers.d.ts b/modifiers.d.ts
|
||||
index 62bf3bccfc29ac73f50f613bb13489751745e77d..21f99a53589fc4a0b88f55f92803036c45160a9d 100644
|
||||
--- a/modifiers.d.ts
|
||||
+++ b/modifiers.d.ts
|
||||
@@ -36,7 +36,7 @@ declare class AxisModifier extends Modifier<DragDropManager<any, any>, Options$1
|
||||
* @param options - The axis restriction options
|
||||
* @returns A configured AxisModifier instance
|
||||
*/
|
||||
- static configure: (options: Options$1) => _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+ static configure: (options: Options$1) => _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
}
|
||||
/**
|
||||
* A pre-configured modifier that restricts movement to the vertical axis.
|
||||
@@ -44,14 +44,14 @@ declare class AxisModifier extends Modifier<DragDropManager<any, any>, Options$1
|
||||
* @remarks
|
||||
* This modifier fixes the x-axis value to 0, allowing only vertical movement.
|
||||
*/
|
||||
-declare const RestrictToVerticalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+declare const RestrictToVerticalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
/**
|
||||
* A pre-configured modifier that restricts movement to the horizontal axis.
|
||||
*
|
||||
* @remarks
|
||||
* This modifier fixes the y-axis value to 0, allowing only horizontal movement.
|
||||
*/
|
||||
-declare const RestrictToHorizontalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+declare const RestrictToHorizontalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
|
||||
/**
|
||||
* Restricts a shape's movement to stay within a bounding rectangle.
|
||||
@@ -128,7 +128,7 @@ declare class SnapModifier extends Modifier<DragDropManager<any, any>, Options>
|
||||
* @param options - The snap grid options
|
||||
* @returns A configured SnapModifier instance
|
||||
*/
|
||||
- static configure: (options: Options) => _dnd_kit_abstract.PluginDescriptor<any, any, typeof SnapModifier>;
|
||||
+ static configure: (options: Options) => _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
}
|
||||
|
||||
export { AxisModifier, RestrictToHorizontalAxis, RestrictToVerticalAxis, SnapModifier, restrictShapeToBoundingRectangle };
|
||||
@@ -71,6 +71,9 @@ overrides:
|
||||
packageExtensionsChecksum: sha256-EMEi1vcyzQthk7O/0AcntvnHgJaKCoFBlzp6iX/qNYk=
|
||||
|
||||
patchedDependencies:
|
||||
'@dnd-kit/abstract':
|
||||
hash: a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1
|
||||
path: patches/@dnd-kit__abstract.patch
|
||||
'@matrix-org/react-sdk-module-api':
|
||||
hash: 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
|
||||
path: patches/@matrix-org__react-sdk-module-api.patch
|
||||
@@ -1022,6 +1025,15 @@ importers:
|
||||
|
||||
packages/shared-components:
|
||||
dependencies:
|
||||
'@dnd-kit/abstract':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/dom':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
'@dnd-kit/react':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: workspace:*
|
||||
version: link:../module-api
|
||||
@@ -2444,6 +2456,27 @@ packages:
|
||||
resolution: {integrity: sha512-dDlz3W405VMFO4w5kIP9DOmELBcvFQGmLoKSdIRstBDubKFYwaNHV1NnlzMCQpXQFGWVALmeMORAuiLx18AvZQ==}
|
||||
engines: {node: '>=14.17.0'}
|
||||
|
||||
'@dnd-kit/abstract@0.4.0':
|
||||
resolution: {integrity: sha512-loEEJxKT5oLOLeRBJVTO9qpgvvW/Qq902xO20v1JMbpANuN/NLurUdpxIwNpVz+RtOSyzznnbc7lO7psmOhc9A==}
|
||||
|
||||
'@dnd-kit/collision@0.4.0':
|
||||
resolution: {integrity: sha512-oOHHUkH1h9Vl2m8TwLw/mPHA7Blf+s0PYcRoLNWNBVxDzugJKZo8WdpU58EMu9qkqyQGrR/YTOozGiMPhlqZ5Q==}
|
||||
|
||||
'@dnd-kit/dom@0.4.0':
|
||||
resolution: {integrity: sha512-mJDKt0BtlHXetZyrvZXh6++aycleIbYWH/OVC4nlszDh8NvW7q8dfsxFllR5RtLKLcykLaI4o545Figfks/HZQ==}
|
||||
|
||||
'@dnd-kit/geometry@0.4.0':
|
||||
resolution: {integrity: sha512-d1n+CU54V/qF/g792bmJK2oR4f5jOL7Pls2IfC+j9f5UBECpjsYbcPZ/krom/z8LgieqvMh1qrUkdcBjJJ7vpg==}
|
||||
|
||||
'@dnd-kit/react@0.4.0':
|
||||
resolution: {integrity: sha512-J2/N4CpQf98zJBZhMljDNsc+QR4VtUKU9BRO1+Di4OGaB1qafMC4qZ11xKXOkjw+d7h82FRSXmXCo0c8+VWaWg==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@dnd-kit/state@0.4.0':
|
||||
resolution: {integrity: sha512-vVdwOY9VsYdMNa7Z0xQhTXlzHqCcCugGuoM1kzvZhnZ0tYVPRdmIhWfeO6Y2ZoN92JwYAyJRRNl4ICkEe2mneg==}
|
||||
|
||||
'@docsearch/css@3.8.2':
|
||||
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
||||
|
||||
@@ -4046,6 +4079,9 @@ packages:
|
||||
'@posthog/types@1.372.8':
|
||||
resolution: {integrity: sha512-ALpfCnWsMSM9Cw/6kyLPVpd81ZReEdZwmDxOi+DTJuIo7wDxBiu2cAsjOuA6D/AL22v7HOJrHsmBAPAWqS5X7Q==}
|
||||
|
||||
'@preact/signals-core@1.14.2':
|
||||
resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==}
|
||||
|
||||
'@principalstudio/html-webpack-inject-preload@1.2.7':
|
||||
resolution: {integrity: sha512-KJKkiKG63ugBjf8U0e9jUcI9CLPTFIsxXplEDE0oi3mPpxd90X9SJovo3W2l7yh/ARKIYXhQq8fSXUN7M29TzQ==}
|
||||
engines: {node: '>=10.23'}
|
||||
@@ -14899,6 +14935,45 @@ snapshots:
|
||||
|
||||
'@discoveryjs/json-ext@1.0.0': {}
|
||||
|
||||
'@dnd-kit/abstract@0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)':
|
||||
dependencies:
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/collision@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/dom@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/collision': 0.4.0
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/geometry@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/react@0.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/dom': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/state@0.4.0':
|
||||
dependencies:
|
||||
'@preact/signals-core': 1.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@docsearch/css@3.8.2': {}
|
||||
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.14)(search-insights@2.17.3)':
|
||||
@@ -16745,6 +16820,8 @@ snapshots:
|
||||
|
||||
'@posthog/types@1.372.8': {}
|
||||
|
||||
'@preact/signals-core@1.14.2': {}
|
||||
|
||||
'@principalstudio/html-webpack-inject-preload@1.2.7(html-webpack-plugin@5.6.7(webpack@5.106.2))(webpack@5.106.2)':
|
||||
dependencies:
|
||||
html-webpack-plugin: 5.6.7(webpack@5.106.2)
|
||||
|
||||