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
This commit is contained in:
Florian Duros
2026-05-13 09:06:22 +00:00
committed by GitHub
parent 97da3be67a
commit 85aca65a81
50 changed files with 4845 additions and 3871 deletions
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

+1 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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;
}
+11 -11
View File
@@ -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();
});
});
});
+2 -1
View File
@@ -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: {
+15
View File
@@ -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:
+13
View File
@@ -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";
+34 -16
View File
@@ -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: {
+39
View File
@@ -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 };
+77
View File
@@ -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)