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 { type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { expect, test } from "../../../element-web-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.describe("Room list custom sections", () => {
|
||||||
test.use({
|
test.use({
|
||||||
@@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => {
|
|||||||
await expect(dialog).not.toBeVisible();
|
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 }) => {
|
test.beforeEach(async ({ page, app, user }) => {
|
||||||
// The notification toast is displayed above the search section
|
// The notification toast is displayed above the search section
|
||||||
await app.closeNotificationToast();
|
await app.closeNotificationToast();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test } from "../../../element-web-test";
|
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.describe("Room list sections", () => {
|
||||||
test.use({
|
test.use({
|
||||||
@@ -182,6 +182,37 @@ test.describe("Room list sections", () => {
|
|||||||
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||||
await expect(roomItem).toBeVisible();
|
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 }) => {
|
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.
|
* 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
|
* 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
|
* Get the primary filters container
|
||||||
* @param page
|
* @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.
|
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";
|
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 { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
|
||||||
import { TagFilter } from "./skip-list/filters/TagFilter";
|
import { TagFilter } from "./skip-list/filters/TagFilter";
|
||||||
import { filterBoolean } from "../../utils/arrays";
|
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.
|
* These are the filters passed to the room skip list.
|
||||||
@@ -86,12 +86,6 @@ export interface Section {
|
|||||||
rooms: Room[];
|
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_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore";
|
|||||||
import Modal from "../../Modal";
|
import Modal from "../../Modal";
|
||||||
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
|
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
|
||||||
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
|
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
|
||||||
|
import { DefaultTagID, type TagID } from "./skip-list/tag";
|
||||||
|
|
||||||
type Tag = string;
|
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.
|
* Prefix for custom section tags.
|
||||||
*/
|
*/
|
||||||
@@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean {
|
|||||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
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.
|
* 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.
|
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
|
* 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 { type Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 RoomListActions from "../../actions/RoomListActions";
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import { getTagsForRoom } from "./getTagsForRoom";
|
import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section";
|
||||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
import { getSectionTagForRoom } from "./getSectionTagForRoom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle tag for a given room.
|
* Toggle tag for a given room.
|
||||||
@@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
|||||||
* @param tagId The tag to invert
|
* @param tagId The tag to invert
|
||||||
*/
|
*/
|
||||||
export function tagRoom(room: Room, tagId: TagID): void {
|
export function tagRoom(room: Room, tagId: TagID): void {
|
||||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
const isChatTag = tagId === CHATS_TAG;
|
||||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
const tag = isChatTag ? null : tagId;
|
||||||
|
|
||||||
|
if (!isSectionTag(tagId)) {
|
||||||
|
logger.warn(`Unexpected tag ${tag} applied to ${room.roomId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||||
const currentSectionTag =
|
const currentSectionTag = getSectionTagForRoom(room);
|
||||||
getTagsForRoom(room).find(
|
|
||||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
const isApplied = currentSectionTag === tagId;
|
const isApplied = currentSectionTag === tag;
|
||||||
const removeTag = currentSectionTag;
|
const removeTag = currentSectionTag;
|
||||||
const addTag = isApplied ? null : tagId;
|
const addTag = isApplied ? null : tag;
|
||||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
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 type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../PosthogTrackers";
|
import PosthogTrackers from "../../PosthogTrackers";
|
||||||
import { type Call, CallEvent } from "../../models/Call";
|
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 { _t } from "../../languageHandler";
|
||||||
|
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||||
|
|
||||||
interface RoomItemProps {
|
interface RoomItemProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -429,9 +430,7 @@ export class RoomListItemViewModel
|
|||||||
RoomListStoreV3.instance.orderedSectionTags
|
RoomListStoreV3.instance.orderedSectionTags
|
||||||
// Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section.
|
// 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.
|
// 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(
|
.filter((tag) => !isDefaultSectionTag(tag))
|
||||||
(tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority,
|
|
||||||
)
|
|
||||||
.map((tag) => ({
|
.map((tag) => ({
|
||||||
tag,
|
tag,
|
||||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
|
|||||||
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
||||||
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
|
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||||
|
|
||||||
interface RoomListSectionHeaderViewModelProps {
|
interface RoomListSectionHeaderViewModelProps {
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel
|
|||||||
private readonly expandedBySpace = new Map<string, boolean>();
|
private readonly expandedBySpace = new Map<string, boolean>();
|
||||||
|
|
||||||
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
||||||
const isDefaultSection =
|
const isDefaultSection = isDefaultSectionTag(props.tag);
|
||||||
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
|
|
||||||
super(props, {
|
super(props, {
|
||||||
id: props.tag,
|
id: props.tag,
|
||||||
title: props.title,
|
title: props.title,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
_t,
|
_t,
|
||||||
type ToastType,
|
type ToastType,
|
||||||
} from "@element-hq/web-shared-components";
|
} 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 { Action } from "../../dispatcher/actions";
|
||||||
import dispatcher from "../../dispatcher/dispatcher";
|
import dispatcher from "../../dispatcher/dispatcher";
|
||||||
@@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
|
|||||||
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
|
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
|
||||||
import SpaceStore from "../../stores/spaces/SpaceStore";
|
import SpaceStore from "../../stores/spaces/SpaceStore";
|
||||||
import RoomListStoreV3, {
|
import RoomListStoreV3, {
|
||||||
CHATS_TAG,
|
|
||||||
RoomListStoreV3Event,
|
RoomListStoreV3Event,
|
||||||
type RoomsResult,
|
type RoomsResult,
|
||||||
type Section,
|
type Section,
|
||||||
@@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame";
|
|||||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||||
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
|
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
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.
|
* Tracks the position of the active room within a specific section.
|
||||||
@@ -667,6 +669,17 @@ export class RoomListViewModel
|
|||||||
this.closeToast();
|
this.closeToast();
|
||||||
}, 15 * 1000);
|
}, 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 { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
|
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
|
||||||
import {
|
import {
|
||||||
CHATS_TAG,
|
|
||||||
LISTS_UPDATE_EVENT,
|
LISTS_UPDATE_EVENT,
|
||||||
SECTION_CREATED_EVENT,
|
SECTION_CREATED_EVENT,
|
||||||
RoomListStoreV3Class,
|
RoomListStoreV3Class,
|
||||||
@@ -37,6 +36,7 @@ import * as utils from "../../../../src/utils/notifications";
|
|||||||
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
|
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
|
||||||
import { Action } from "../../../../src/dispatcher/actions";
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
|
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
|
||||||
|
import { CHATS_TAG } from "../../../../src/stores/room-list-v3/section";
|
||||||
|
|
||||||
describe("RoomListStoreV3", () => {
|
describe("RoomListStoreV3", () => {
|
||||||
async function getRoomListStore() {
|
async function getRoomListStore() {
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
|
|
||||||
import Modal from "../../../../src/Modal";
|
import Modal from "../../../../src/Modal";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
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 { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
|
||||||
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
|
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
|
||||||
|
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||||
|
|
||||||
describe("section", () => {
|
describe("section", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -203,4 +212,27 @@ describe("section", () => {
|
|||||||
expect(customDataCall![3]).not.toHaveProperty(tag);
|
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 RoomListActions from "../../../../src/actions/RoomListActions";
|
||||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
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 { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||||
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
import * as getSectionTagForRoomUtils from "../../../../src/utils/room/getSectionTagForRoom";
|
||||||
|
|
||||||
describe("tagRoom()", () => {
|
describe("tagRoom()", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
const roomId = "!room:server.org";
|
const roomId = "!room:server.org";
|
||||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
||||||
|
|
||||||
const makeRoom = (tags: TagID[] = []): Room => {
|
const makeRoom = (currentSectionTag: TagID | null = null): Room => {
|
||||||
const client = getMockClientWithEventEmitter({
|
const client = getMockClientWithEventEmitter({
|
||||||
isGuest: jest.fn(),
|
isGuest: jest.fn(),
|
||||||
});
|
});
|
||||||
const room = new Room(roomId, client, userId);
|
const room = new Room(roomId, client, userId);
|
||||||
|
|
||||||
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags);
|
jest.spyOn(getSectionTagForRoomUtils, "getSectionTagForRoom").mockReturnValue(currentSectionTag);
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
};
|
};
|
||||||
@@ -51,7 +51,7 @@ describe("tagRoom()", () => {
|
|||||||
expect(RoomListActions.tagRoom).not.toHaveBeenCalled();
|
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", () => {
|
it("should tag a room as favourite", () => {
|
||||||
const room = makeRoom();
|
const room = makeRoom();
|
||||||
|
|
||||||
@@ -93,11 +93,25 @@ describe("tagRoom()", () => {
|
|||||||
customTag, // add
|
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", () => {
|
describe("when a room is tagged as favourite", () => {
|
||||||
it("should unfavourite a room", () => {
|
it("should unfavourite a room", () => {
|
||||||
const room = makeRoom([DefaultTagID.Favourite]);
|
const room = makeRoom(DefaultTagID.Favourite);
|
||||||
|
|
||||||
tagRoom(room, DefaultTagID.Favourite);
|
tagRoom(room, DefaultTagID.Favourite);
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ describe("tagRoom()", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should tag a room low priority", () => {
|
it("should tag a room low priority", () => {
|
||||||
const room = makeRoom([DefaultTagID.Favourite]);
|
const room = makeRoom(DefaultTagID.Favourite);
|
||||||
|
|
||||||
tagRoom(room, DefaultTagID.LowPriority);
|
tagRoom(room, DefaultTagID.LowPriority);
|
||||||
|
|
||||||
@@ -123,10 +137,25 @@ describe("tagRoom()", () => {
|
|||||||
DefaultTagID.LowPriority, // add
|
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", () => {
|
describe("when a room is tagged as low priority", () => {
|
||||||
it("should favourite a room", () => {
|
it("should favourite a room", () => {
|
||||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
const room = makeRoom(DefaultTagID.LowPriority);
|
||||||
|
|
||||||
tagRoom(room, DefaultTagID.Favourite);
|
tagRoom(room, DefaultTagID.Favourite);
|
||||||
|
|
||||||
@@ -140,7 +169,7 @@ describe("tagRoom()", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should untag a room low priority", () => {
|
it("should untag a room low priority", () => {
|
||||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
const room = makeRoom(DefaultTagID.LowPriority);
|
||||||
|
|
||||||
tagRoom(room, DefaultTagID.LowPriority);
|
tagRoom(room, DefaultTagID.LowPriority);
|
||||||
|
|
||||||
@@ -161,8 +190,9 @@ describe("tagRoom()", () => {
|
|||||||
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
|
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
|
||||||
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
|
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
|
||||||
{ label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag },
|
{ 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 }) => {
|
])("should $label", ({ applyTag, expectedAdd }) => {
|
||||||
const room = makeRoom([customTag]);
|
const room = makeRoom(customTag);
|
||||||
|
|
||||||
tagRoom(room, applyTag);
|
tagRoom(room, applyTag);
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ import { Action } from "../../../src/dispatcher/actions";
|
|||||||
import { CallStore } from "../../../src/stores/CallStore";
|
import { CallStore } from "../../../src/stores/CallStore";
|
||||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
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 * as tagRoomModule from "../../../src/utils/room/tagRoom";
|
||||||
|
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||||
|
|
||||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import { RoomNotificationStateStore } from "../../../src/stores/notifications/Ro
|
|||||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||||
import { createTestClient, mkRoom } from "../../test-utils";
|
import { createTestClient, mkRoom } from "../../test-utils";
|
||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
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 { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||||
|
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||||
|
|
||||||
describe("RoomListSectionHeaderViewModel", () => {
|
describe("RoomListSectionHeaderViewModel", () => {
|
||||||
let onToggleExpanded: jest.Mock;
|
let onToggleExpanded: jest.Mock;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { mocked } from "jest-mock";
|
|||||||
import { waitFor } from "jest-matrix-react";
|
import { waitFor } from "jest-matrix-react";
|
||||||
|
|
||||||
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
|
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 SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||||
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
||||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
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 { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
||||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
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", () => ({
|
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||||
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
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",
|
"postcss-mixins": "patches/postcss-mixins.patch",
|
||||||
"app-builder-lib": "patches/app-builder-lib.patch",
|
"app-builder-lib": "patches/app-builder-lib.patch",
|
||||||
"knip": "patches/knip.patch",
|
"knip": "patches/knip.patch",
|
||||||
"plist": "patches/plist.patch"
|
"plist": "patches/plist.patch",
|
||||||
|
"@dnd-kit/abstract": "patches/@dnd-kit__abstract.patch"
|
||||||
},
|
},
|
||||||
"peerDependencyRules": {
|
"peerDependencyRules": {
|
||||||
"allowedVersions": {
|
"allowedVersions": {
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import "./app-web-root.css";
|
|||||||
import "./preview.css";
|
import "./preview.css";
|
||||||
import React, { useLayoutEffect } from "react";
|
import React, { useLayoutEffect } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
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 { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
|
||||||
import { setLanguage } from "../src/core/i18n/i18n";
|
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 = {
|
export const globalTypes = {
|
||||||
theme: {
|
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"],
|
tags: ["autodocs", "snapshot"],
|
||||||
initialGlobals: {
|
initialGlobals: {
|
||||||
rootCss: "storybook",
|
rootCss: "storybook",
|
||||||
@@ -181,7 +204,14 @@ const preview = {
|
|||||||
eventLayout: "group",
|
eventLayout: "group",
|
||||||
eventDensity: "default",
|
eventDensity: "default",
|
||||||
},
|
},
|
||||||
decorators: [withRootCss, withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
|
decorators: [
|
||||||
|
withRootCss,
|
||||||
|
withThemeProvider,
|
||||||
|
withEventPresentationProvider,
|
||||||
|
withTooltipProvider,
|
||||||
|
withI18nProvider,
|
||||||
|
withDragDropProvider,
|
||||||
|
],
|
||||||
parameters: {
|
parameters: {
|
||||||
options: {
|
options: {
|
||||||
storySort: {
|
storySort: {
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ or in CSS file:
|
|||||||
@import url("@element-hq/web-shared-components");
|
@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
|
### Using Components
|
||||||
|
|
||||||
There are two kinds of components in this library:
|
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"
|
"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": {
|
"./dist/element-web-shared-components.css": {
|
||||||
"require": "./dist/element-web-shared-components.css",
|
"require": "./dist/element-web-shared-components.css",
|
||||||
"import": "./dist/element-web-shared-components.css"
|
"import": "./dist/element-web-shared-components.css"
|
||||||
@@ -51,6 +61,9 @@
|
|||||||
"lint:types": "nx lint:types"
|
"lint:types": "nx lint:types"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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:*",
|
"@element-hq/element-web-module-api": "workspace:*",
|
||||||
"@matrix-org/spec": "^1.7.0",
|
"@matrix-org/spec": "^1.7.0",
|
||||||
"@vector-im/compound-design-tokens": "catalog:",
|
"@vector-im/compound-design-tokens": "catalog:",
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ export interface VirtualizedListProps<Item, Context> extends Omit<
|
|||||||
*/
|
*/
|
||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
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).
|
* 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.
|
* If provided, this will be used instead of items.length for the total count.
|
||||||
@@ -164,6 +171,7 @@ export function useVirtualizedList<Item, Context>(
|
|||||||
getItemKey,
|
getItemKey,
|
||||||
context,
|
context,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
|
disableKeyboardNavigation,
|
||||||
totalCount,
|
totalCount,
|
||||||
rangeChanged,
|
rangeChanged,
|
||||||
mapScrollIndex,
|
mapScrollIndex,
|
||||||
@@ -260,6 +268,13 @@ export function useVirtualizedList<Item, Context>(
|
|||||||
return;
|
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) {
|
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
|
||||||
scrollToItem(currentIndex - 1, false);
|
scrollToItem(currentIndex - 1, false);
|
||||||
handled = true;
|
handled = true;
|
||||||
@@ -300,7 +315,16 @@ export function useVirtualizedList<Item, Context>(
|
|||||||
onKeyDown?.(e);
|
onKeyDown?.(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
|
[
|
||||||
|
scrollToIndex,
|
||||||
|
scrollToItem,
|
||||||
|
tabIndexKey,
|
||||||
|
keyToIndexMap,
|
||||||
|
visibleRange,
|
||||||
|
items,
|
||||||
|
onKeyDown,
|
||||||
|
disableKeyboardNavigation,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const RoomListViewWrapperImpl = ({
|
|||||||
updateVisibleRooms,
|
updateVisibleRooms,
|
||||||
renderAvatar: renderAvatarProp,
|
renderAvatar: renderAvatarProp,
|
||||||
closeToast,
|
closeToast,
|
||||||
|
changeRoomSection,
|
||||||
...rest
|
...rest
|
||||||
}: RoomListViewProps): JSX.Element => {
|
}: RoomListViewProps): JSX.Element => {
|
||||||
const vm = useMockedViewModel(rest, {
|
const vm = useMockedViewModel(rest, {
|
||||||
@@ -50,6 +51,7 @@ const RoomListViewWrapperImpl = ({
|
|||||||
getSectionHeaderViewModel,
|
getSectionHeaderViewModel,
|
||||||
updateVisibleRooms,
|
updateVisibleRooms,
|
||||||
closeToast,
|
closeToast,
|
||||||
|
changeRoomSection,
|
||||||
});
|
});
|
||||||
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||||
};
|
};
|
||||||
@@ -102,6 +104,7 @@ const meta = {
|
|||||||
isFlatList: true,
|
isFlatList: true,
|
||||||
toast: undefined,
|
toast: undefined,
|
||||||
closeToast: fn(),
|
closeToast: fn(),
|
||||||
|
changeRoomSection: fn(),
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
design: {
|
design: {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface RoomListViewActions {
|
|||||||
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
|
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
|
||||||
/** Called to close the toast message */
|
/** Called to close the toast message */
|
||||||
closeToast: () => void;
|
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;
|
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 {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 classNames from "classnames";
|
||||||
import { Text } from "@vector-im/compound-web";
|
import { useMergeRefs } from "react-merge-refs";
|
||||||
|
|
||||||
import { Flex } from "../../../../core/utils/Flex";
|
import { Flex } from "../../../../core/utils/Flex";
|
||||||
import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
|
import { type NotificationDecorationData } from "./NotificationDecoration";
|
||||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
|
||||||
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||||
|
import { RoomListItemContent } from "./RoomListItemContent";
|
||||||
import { type RoomNotifState } from "./RoomNotifs";
|
import { type RoomNotifState } from "./RoomNotifs";
|
||||||
import styles from "./RoomListItemView.module.css";
|
import styles from "./RoomListItemView.module.css";
|
||||||
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
|
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
|
||||||
@@ -150,6 +150,7 @@ export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLBut
|
|||||||
isLastItem: boolean;
|
isLastItem: boolean;
|
||||||
/** Function to render the room avatar */
|
/** Function to render the room avatar */
|
||||||
renderAvatar: (room: Room) => ReactNode;
|
renderAvatar: (room: Room) => ReactNode;
|
||||||
|
ref?: Ref<Element>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({
|
|||||||
isFirstItem,
|
isFirstItem,
|
||||||
isLastItem,
|
isLastItem,
|
||||||
renderAvatar,
|
renderAvatar,
|
||||||
|
ref,
|
||||||
...props
|
...props
|
||||||
}: RoomListItemViewProps): JSX.Element {
|
}: RoomListItemViewProps): JSX.Element {
|
||||||
const ref = useRef<HTMLButtonElement>(null);
|
const internalRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const mergedRef = useMergeRefs([ref, internalRef]);
|
||||||
const item = useViewModel(vm);
|
const item = useViewModel(vm);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions);
|
internalRef.current?.focus({ preventScroll: true } as FocusOptions);
|
||||||
}
|
}
|
||||||
}, [isFocused]);
|
}, [isFocused]);
|
||||||
|
|
||||||
@@ -182,7 +185,7 @@ export const RoomListItemView = memo(function RoomListItemView({
|
|||||||
<RoomListItemContextMenu vm={vm}>
|
<RoomListItemContextMenu vm={vm}>
|
||||||
<Flex
|
<Flex
|
||||||
as="button"
|
as="button"
|
||||||
ref={ref}
|
ref={mergedRef}
|
||||||
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
|
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
|
||||||
[styles.selected]: isSelected,
|
[styles.selected]: isSelected,
|
||||||
[styles.bold]: item.isBold,
|
[styles.bold]: item.isBold,
|
||||||
@@ -193,41 +196,14 @@ export const RoomListItemView = memo(function RoomListItemView({
|
|||||||
gap="var(--cpd-space-3x)"
|
gap="var(--cpd-space-3x)"
|
||||||
align="stretch"
|
align="stretch"
|
||||||
type="button"
|
type="button"
|
||||||
aria-selected={isSelected}
|
|
||||||
aria-label={a11yLabel}
|
aria-label={a11yLabel}
|
||||||
onClick={vm.onOpenRoom}
|
onClick={vm.onOpenRoom}
|
||||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
|
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
|
||||||
tabIndex={isFocused ? 0 : -1}
|
tabIndex={isFocused ? 0 : -1}
|
||||||
|
aria-selected={props.role === "option" ? isSelected : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
|
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} />
|
||||||
{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>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</RoomListItemContextMenu>
|
</RoomListItemContextMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type {
|
|||||||
RoomListItemViewProps,
|
RoomListItemViewProps,
|
||||||
Section,
|
Section,
|
||||||
} from "./RoomListItemView";
|
} from "./RoomListItemView";
|
||||||
|
export { RoomListItemContent } from "./RoomListItemContent";
|
||||||
|
export type { RoomListItemContentProps } from "./RoomListItemContent";
|
||||||
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||||
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
||||||
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
||||||
|
|||||||
@@ -6,9 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, type JSX } from "react";
|
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 { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
|
||||||
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
|
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
|
||||||
|
import { useViewModel } from "../../../core/viewmodel";
|
||||||
|
|
||||||
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
|
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
|
||||||
/** Index of this room in the list */
|
/** 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.
|
* Wraps RoomListItemView with the correct accessibility and drag-and-drop props
|
||||||
* In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props.
|
* based on whether the list is flat (listbox) or grouped (treegrid).
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ``
|
|
||||||
* <RoomListItemWrapper
|
|
||||||
* roomIndex={0}
|
|
||||||
* roomIndexInSection={0}
|
|
||||||
* roomCount={10}
|
|
||||||
* isInFlatList={true}
|
|
||||||
* {...otherRoomListItemViewProps}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export const RoomListItemWrapper = memo(function RoomListItemWrapper({
|
export const RoomListItemWrapper = memo(function RoomListItemWrapper({
|
||||||
roomIndex,
|
roomIndex,
|
||||||
@@ -43,9 +37,30 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({
|
|||||||
isInFlatList,
|
isInFlatList,
|
||||||
...rest
|
...rest
|
||||||
}: RoomListItemWrapperProps): JSX.Element {
|
}: RoomListItemWrapperProps): JSX.Element {
|
||||||
const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" };
|
if (isInFlatList) {
|
||||||
const item = <RoomListItemView {...rest} {...itemA11yProps} />;
|
return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isInFlatList) return item;
|
return (
|
||||||
return <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>{item}</div>;
|
<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;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropTarget {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||||
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
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 { useViewModel, type ViewModel } from "../../../core/viewmodel";
|
||||||
import styles from "./RoomListSectionHeaderView.module.css";
|
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 { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
|
||||||
const isLastSection = sectionIndex === sectionCount - 1;
|
const isLastSection = sectionIndex === sectionCount - 1;
|
||||||
|
|
||||||
|
const { ref, isDropTarget } = useDroppable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
|
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
className={classNames(styles.header, {
|
className={classNames(styles.header, {
|
||||||
@@ -127,7 +133,14 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
|||||||
: _t("room_list|section_header|toggle", { section: title })
|
: _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)">
|
<Flex align="center" gap="var(--cpd-space-0-5x)">
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
className={styles.chevron}
|
className={styles.chevron}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const RoomListWrapperImpl = ({
|
|||||||
updateVisibleRooms,
|
updateVisibleRooms,
|
||||||
closeToast,
|
closeToast,
|
||||||
renderAvatar: renderAvatarProp,
|
renderAvatar: renderAvatarProp,
|
||||||
|
changeRoomSection,
|
||||||
...rest
|
...rest
|
||||||
}: RoomListStoryProps): JSX.Element => {
|
}: RoomListStoryProps): JSX.Element => {
|
||||||
const vm = useMockedViewModel(rest, {
|
const vm = useMockedViewModel(rest, {
|
||||||
@@ -46,6 +47,7 @@ const RoomListWrapperImpl = ({
|
|||||||
getSectionHeaderViewModel,
|
getSectionHeaderViewModel,
|
||||||
updateVisibleRooms,
|
updateVisibleRooms,
|
||||||
closeToast,
|
closeToast,
|
||||||
|
changeRoomSection,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,6 +87,7 @@ const meta = {
|
|||||||
renderAvatar,
|
renderAvatar,
|
||||||
isFlatList: true,
|
isFlatList: true,
|
||||||
closeToast: fn(),
|
closeToast: fn(),
|
||||||
|
changeRoomSection: fn(),
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
design: {
|
design: {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
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 { VirtuosoMockContext } from "react-virtuoso";
|
||||||
import { composeStories } from "@storybook/react-vite";
|
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";
|
import * as stories from "./VirtualizedRoomListView.stories";
|
||||||
|
|
||||||
@@ -65,6 +66,38 @@ describe("<VirtualizedRoomListView />", () => {
|
|||||||
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
|
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", () => {
|
describe("scrollToSectionTag", () => {
|
||||||
it("skips scroll when scrollToSectionTag does not match any section", () => {
|
it("skips scroll when scrollToSectionTag does not match any section", () => {
|
||||||
const roomListState = {
|
const roomListState = {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
|
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
|
||||||
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
|
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
|
||||||
import { isEqual } from "lodash";
|
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 { type Room } from "./RoomListItemWrapper/RoomListItemView";
|
||||||
import { useViewModel } from "../../core/viewmodel";
|
import { useViewModel } from "../../core/viewmodel";
|
||||||
@@ -18,9 +20,10 @@ import {
|
|||||||
type VirtualizedListContext,
|
type VirtualizedListContext,
|
||||||
} from "../../core/VirtualizedList";
|
} from "../../core/VirtualizedList";
|
||||||
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
|
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
|
||||||
import { GroupedVirtualizedList } from "../../core/VirtualizedList";
|
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
|
||||||
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
||||||
import { RoomListItemWrapper } from "./RoomListItemWrapper";
|
import { RoomListItemWrapper } from "./RoomListItemWrapper";
|
||||||
|
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||||
import styles from "./VirtualizedRoomListView.module.css";
|
import styles from "./VirtualizedRoomListView.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -383,15 +386,78 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupedVirtualizedList<string, string, Context>
|
<DragDropProvider
|
||||||
{...commonProps}
|
onDragEnd={(event) => {
|
||||||
{...getContainerAccessibleProps("treegrid", totalCount)}
|
if (event.canceled) return;
|
||||||
scrollHandleRef={setVirtuosoHandle}
|
const { target, source } = event.operation;
|
||||||
groups={groups}
|
if (!source || !target) return;
|
||||||
getHeaderKey={getHeaderKey}
|
|
||||||
getGroupHeaderComponent={getGroupHeaderComponent}
|
vm.changeRoomSection(source.id as string, target.id as string);
|
||||||
getItemComponent={getItemComponentForGroupedList}
|
}}
|
||||||
isGroupHeaderFocusable={isGroupHeaderFocusable}
|
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 type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";
|
||||||
export * from "./RoomListSectionHeaderView";
|
export * from "./RoomListSectionHeaderView";
|
||||||
export * from "./RoomListItemWrapper";
|
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 { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig, esmExternalRequirePlugin, type Plugin } from "vite";
|
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 cssLayerOrder = "@layer compound-tokens, compound-web, shared-components, app-web;";
|
||||||
const sharedComponentsLayer = "shared-components";
|
const sharedComponentsLayer = "shared-components";
|
||||||
|
|
||||||
|
const cssAssetFileName = "element-web-shared-components.css";
|
||||||
|
|
||||||
function layerCssAssets(): Plugin {
|
function layerCssAssets(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "element-web-shared-components-css-layer",
|
name: "element-web-shared-components-css-layer",
|
||||||
writeBundle(_options, bundle): void {
|
// Rename + layer-wrap the emitted CSS file. With multi-entry lib mode,
|
||||||
for (const asset of Object.values(bundle)) {
|
// vite/rolldown derives CSS filenames from the unscoped package name (dropping
|
||||||
if (asset.type !== "asset" || asset.fileName !== "element-web-shared-components.css") {
|
// the `element-` prefix), so we rename on disk to keep the path stable for
|
||||||
continue;
|
// 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);
|
if (existsSync(renamedFromPath)) {
|
||||||
const source = readFileSync(cssPath, "utf-8");
|
renameSync(renamedFromPath, expectedPath);
|
||||||
if (source.startsWith(cssLayerOrder)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(cssPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
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",
|
name: "Element Web Shared Components",
|
||||||
// the proper extensions will be added
|
// Multi-entry mode needs both formats explicit; UMD doesn't support multi-entry
|
||||||
fileName: "element-web-shared-components",
|
// (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",
|
outDir: "dist",
|
||||||
rolldownOptions: {
|
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=
|
packageExtensionsChecksum: sha256-EMEi1vcyzQthk7O/0AcntvnHgJaKCoFBlzp6iX/qNYk=
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
'@dnd-kit/abstract':
|
||||||
|
hash: a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1
|
||||||
|
path: patches/@dnd-kit__abstract.patch
|
||||||
'@matrix-org/react-sdk-module-api':
|
'@matrix-org/react-sdk-module-api':
|
||||||
hash: 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
|
hash: 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
|
||||||
path: patches/@matrix-org__react-sdk-module-api.patch
|
path: patches/@matrix-org__react-sdk-module-api.patch
|
||||||
@@ -1022,6 +1025,15 @@ importers:
|
|||||||
|
|
||||||
packages/shared-components:
|
packages/shared-components:
|
||||||
dependencies:
|
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':
|
'@element-hq/element-web-module-api':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../module-api
|
version: link:../module-api
|
||||||
@@ -2444,6 +2456,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-dDlz3W405VMFO4w5kIP9DOmELBcvFQGmLoKSdIRstBDubKFYwaNHV1NnlzMCQpXQFGWVALmeMORAuiLx18AvZQ==}
|
resolution: {integrity: sha512-dDlz3W405VMFO4w5kIP9DOmELBcvFQGmLoKSdIRstBDubKFYwaNHV1NnlzMCQpXQFGWVALmeMORAuiLx18AvZQ==}
|
||||||
engines: {node: '>=14.17.0'}
|
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':
|
'@docsearch/css@3.8.2':
|
||||||
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
||||||
|
|
||||||
@@ -4046,6 +4079,9 @@ packages:
|
|||||||
'@posthog/types@1.372.8':
|
'@posthog/types@1.372.8':
|
||||||
resolution: {integrity: sha512-ALpfCnWsMSM9Cw/6kyLPVpd81ZReEdZwmDxOi+DTJuIo7wDxBiu2cAsjOuA6D/AL22v7HOJrHsmBAPAWqS5X7Q==}
|
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':
|
'@principalstudio/html-webpack-inject-preload@1.2.7':
|
||||||
resolution: {integrity: sha512-KJKkiKG63ugBjf8U0e9jUcI9CLPTFIsxXplEDE0oi3mPpxd90X9SJovo3W2l7yh/ARKIYXhQq8fSXUN7M29TzQ==}
|
resolution: {integrity: sha512-KJKkiKG63ugBjf8U0e9jUcI9CLPTFIsxXplEDE0oi3mPpxd90X9SJovo3W2l7yh/ARKIYXhQq8fSXUN7M29TzQ==}
|
||||||
engines: {node: '>=10.23'}
|
engines: {node: '>=10.23'}
|
||||||
@@ -14899,6 +14935,45 @@ snapshots:
|
|||||||
|
|
||||||
'@discoveryjs/json-ext@1.0.0': {}
|
'@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/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)':
|
'@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': {}
|
'@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)':
|
'@principalstudio/html-webpack-inject-preload@1.2.7(html-webpack-plugin@5.6.7(webpack@5.106.2))(webpack@5.106.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
html-webpack-plugin: 5.6.7(webpack@5.106.2)
|
html-webpack-plugin: 5.6.7(webpack@5.106.2)
|
||||||
|
|||||||