Room list: add drag and drop of sections to reorder them (#33606)
* feat(rls): centralize room-list section ordering via getOrderedSections Introduce a single source of truth for the ordered list of section tags (defaults + custom) in section.ts. RoomListStoreV3 no longer hard-codes the default tag order — it asks section.ts. * feat(vm): add section reorder logic to RoomListStoreV3 and view model * refactor(sc): extract RoomListSectionHeaderContent from RoomListSectionHeaderView * feat(sc): add drag-and-drop reordering for room-list sections * test(sc): update existing tests * test: add new tests * test(sc): add snapshot test for overlay * test(e2e): add playright test for dnd of sections * feat: tweak opacity * fix: section detection when dragged * feat: use relative position in section order * chore: move style to room list section header view * test: add e2e test for moving section before another * feat: make favourite and low priority no draggable * test: remove deprecated e2e test * fix: type correctly dnd provider and hooks * fix: use String instead of casting to string * fix: wrong a11y attributes on section header * test: fix keyboard navigation e2e tests * feat: use custom a11y announcement for dnd * fix: add aria-hidden to the overlay * chore: remove duplicated code in a11Y announcement * fix: increase keyboard drag offset * fix: tests * fix: virtuoso computed item key * fix: aria-expanded double annoncement why dragging * fix: re-add screen reader instructions * chore: remove unused import * fix: reduce keyboard drag offset * fix: improve text readback on unread section * feat: use a custom a11y plugin instead of buitin a11y plugin * chore: formatting * test: fix incorrect tests * chore: use randomUUID * fix: try to make test working * fix: put back mock * fix: circular import * Revert "fix: circular import" This reverts commit 6c69313ade9ed2ab17a4622c692b435fbdb94ad2. * chore: add @dnd-kit/abstract to optimizeDeps.include * test: fix e2e tests * fix: disable interaction with section when dragging * fix: be more explicit if the section will be dropped before or after * fix: add info that space is dropping too * fix: scroll to dropped section * test: fix virtualized room list test * chore: update lang * chore: fix dead code analyze * test: add provider section story * fix: lang * fix: again lang... * fix: improve voice over on chrome * test: upate snapshot * fix: add readback when a section is over a non droppable element
This commit is contained in:
+36
-1
@@ -9,7 +9,15 @@ import { type Page } from "@playwright/test";
|
||||
import { closeReleaseAnnouncement, rejectToast } from "@element-hq/element-web-playwright-common";
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { assertRoomInSection, dragRoomToSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
import {
|
||||
assertRoomInSection,
|
||||
assertSectionsOrder,
|
||||
dragRoomToSection,
|
||||
dragSectionToSection,
|
||||
getRoomList,
|
||||
getRoomListHeader,
|
||||
getSectionHeader,
|
||||
} from "./utils";
|
||||
|
||||
test.describe("Room list custom sections", () => {
|
||||
test.use({
|
||||
@@ -287,6 +295,33 @@ test.describe("Room list custom sections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Section reordering via dnd", () => {
|
||||
test("should reorder custom sections via dnd", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
await createCustomSection(page, "Personal");
|
||||
|
||||
// Default placement: custom sections sit at the top of Chats
|
||||
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
|
||||
|
||||
// Moves Work after Chats
|
||||
await dragSectionToSection(page, "Work", "Chats");
|
||||
await assertSectionsOrder(page, ["Personal", "Chats", "Work"]);
|
||||
});
|
||||
|
||||
test("should insert a section before the target when dragging up", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
await createCustomSection(page, "Work");
|
||||
await createCustomSection(page, "Personal");
|
||||
|
||||
await assertSectionsOrder(page, ["Work", "Personal", "Chats"]);
|
||||
|
||||
// Personal sits below Work, so dragging it onto Work inserts it before Work.
|
||||
await dragSectionToSection(page, "Personal", "Work");
|
||||
await assertSectionsOrder(page, ["Personal", "Work", "Chats"]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Adding a room to a custom section", () => {
|
||||
test("should add a room to a custom section via the More Options menu", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
|
||||
@@ -30,8 +30,8 @@ export function getRoomListHeader(page: Page): Locator {
|
||||
* @param isUnread Whether to look for the unread version of the section header
|
||||
*/
|
||||
export function getSectionHeader(page: Page, sectionName: string, isUnread = false): Locator {
|
||||
return getRoomList(page).getByRole("gridcell", {
|
||||
name: isUnread ? `Toggle ${sectionName} section with unread room(s)` : `Toggle ${sectionName} section`,
|
||||
return getRoomList(page).getByRole("button", {
|
||||
name: isUnread ? `Toggle ${sectionName} section with unread rooms` : `Toggle ${sectionName} section`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +106,60 @@ async function getBoundingBox(
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag and drop a section header onto another section header. The dragged section is moved
|
||||
* relative to the target: dropped before the target when dragging up, after the target when
|
||||
* dragging down. Because the dnd start handler collapses every section, the layout changes
|
||||
* once the drag activates — so the target position is recomputed after activation rather
|
||||
* than cached up-front.
|
||||
*/
|
||||
export async function dragSectionToSection(
|
||||
page: Page,
|
||||
sourceSectionName: string,
|
||||
targetSectionName: string,
|
||||
): Promise<void> {
|
||||
const source = getSectionHeader(page, sourceSectionName);
|
||||
const sourceBox = await source.boundingBox();
|
||||
if (!sourceBox) throw new Error(`Source section ${sourceSectionName} has no bounding box`);
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
|
||||
// Grab the section header
|
||||
await page.mouse.move(sourceX, sourceY);
|
||||
await page.mouse.down();
|
||||
// Move past the 5px PointerSensor activation threshold so the drag actually starts.
|
||||
// This triggers onSectionDragStart, which collapses all sections.
|
||||
await page.mouse.move(sourceX, sourceY + 10, { steps: 5 });
|
||||
|
||||
// Re-query the target now that the layout has reflowed.
|
||||
const target = getSectionHeader(page, targetSectionName);
|
||||
const targetBox = await target.boundingBox();
|
||||
if (!targetBox) throw new Error(`Target section ${targetSectionName} has no bounding box`);
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Move onto the (possibly relocated) target section header and drop.
|
||||
await page.mouse.move(sourceX, targetY, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the displayed section headers appear in the given top-to-bottom order.
|
||||
*/
|
||||
export async function assertSectionsOrder(page: Page, expectedOrder: string[]): Promise<void> {
|
||||
const positions: Array<{ name: string; y: number }> = [];
|
||||
for (const name of expectedOrder) {
|
||||
const header = getSectionHeader(page, name);
|
||||
await expect(header).toBeVisible();
|
||||
const box = await header.boundingBox();
|
||||
if (!box) throw new Error(`Section ${name} has no bounding box`);
|
||||
positions.push({ name, y: box.y });
|
||||
}
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
expect(positions[i].y).toBeGreaterThan(positions[i - 1].y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary filters container
|
||||
* @param page
|
||||
|
||||
@@ -53,7 +53,7 @@ import InviteRulesConfigController from "./controllers/InviteRulesConfigControll
|
||||
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
|
||||
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
|
||||
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
|
||||
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
|
||||
import { type ReorderableSection, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
|
||||
import { type NotificationSound } from "../Notifier.ts";
|
||||
import VideoRoomsBetaImage from "../../res/img/betas/video_rooms.png";
|
||||
|
||||
@@ -371,7 +371,7 @@ export interface Settings {
|
||||
"blockInvites": IBaseSetting<boolean>;
|
||||
"Developer.elementCallUrl": IBaseSetting<string>;
|
||||
"RoomList.CustomSectionData": IBaseSetting<CustomSectionsData>;
|
||||
"RoomList.OrderedCustomSections": IBaseSetting<OrderedCustomSections>;
|
||||
"RoomList.OrderedCustomSections": IBaseSetting<ReorderableSection[]>;
|
||||
}
|
||||
|
||||
export type SettingKey = keyof Settings;
|
||||
|
||||
@@ -36,11 +36,18 @@ import { UnreadSorter } from "./skip-list/sorters/UnreadSorter";
|
||||
import { getChangedOverrideRoomMutePushRules } from "./utils";
|
||||
import { isRoomVisible } from "./isRoomVisible";
|
||||
import { RoomSkipList } from "./skip-list/RoomSkipList";
|
||||
import { DefaultTagID } from "./skip-list/tag";
|
||||
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
|
||||
import { TagFilter } from "./skip-list/filters/TagFilter";
|
||||
import { filterBoolean } from "../../utils/arrays";
|
||||
import { CHATS_TAG, createSection, deleteSection, editSection, getOrderedCustomSections } from "./section";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
createSection,
|
||||
deleteSection,
|
||||
editSection,
|
||||
getOrderedReorderableSections,
|
||||
reorderSection,
|
||||
} from "./section";
|
||||
import { DefaultTagID } from "./skip-list/tag";
|
||||
|
||||
/**
|
||||
* These are the filters passed to the room skip list.
|
||||
@@ -513,6 +520,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
this.scheduleEmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder custom sections by moving sourceTag to the position of targetTag.
|
||||
* @param sourceTag The tag of the section to move
|
||||
* @param targetTag The tag of the section to move to
|
||||
*/
|
||||
public async reorderSection(sourceTag: string, targetTag: string): Promise<void> {
|
||||
await reorderSection(sourceTag, targetTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered section tags.
|
||||
*/
|
||||
@@ -524,8 +540,10 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
|
||||
* Load the custom sections from the settings store and update the sorted tags.
|
||||
*/
|
||||
private loadCustomSections(): void {
|
||||
const orderedCustomSections = getOrderedCustomSections();
|
||||
this.sortedTags = [DefaultTagID.Favourite, ...orderedCustomSections, CHATS_TAG, DefaultTagID.LowPriority];
|
||||
// Favourite is pinned to the top and LowPriority to the bottom. Everything in between
|
||||
// (custom sections + Chats) is user-reorderable.
|
||||
const reorderable = getOrderedReorderableSections();
|
||||
this.sortedTags = [DefaultTagID.Favourite, ...reorderable, DefaultTagID.LowPriority];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,11 +82,25 @@ function isValidCustomSection(value: unknown): value is CustomSection {
|
||||
* The custom sections data is stored as a record in the settings, where the key is the section tag and the value is the section data (name and tag).
|
||||
*/
|
||||
export type CustomSectionsData = Record<CustomTag, CustomSection>;
|
||||
|
||||
/**
|
||||
* Ordered list of custom section tags.
|
||||
*/
|
||||
export type OrderedCustomSections = CustomTag[];
|
||||
|
||||
/**
|
||||
* Tags that can be reordered relative to each other (everything except Favourite and LowPriority,
|
||||
* which are pinned to the top and bottom respectively).
|
||||
*/
|
||||
export type ReorderableSection = CustomTag | typeof CHATS_TAG;
|
||||
|
||||
/**
|
||||
* Returns true if the given tag is a tag that can be reordered (custom section or the Chats tag).
|
||||
*/
|
||||
function isReorderableSection(tag: string, customData: CustomSectionsData): tag is ReorderableSection {
|
||||
return tag === CHATS_TAG || (isCustomSectionTag(tag) && tag in customData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given space key corresponds to an enabled meta-space or a known top-level space room.
|
||||
*/
|
||||
@@ -121,12 +135,31 @@ export function getCustomSectionData(): CustomSectionsData {
|
||||
/**
|
||||
* Retrieves the ordered list of custom section tags from the settings.
|
||||
* If the settings contain tags that are not present in the custom section data, they will be filtered out and the settings will be updated to remove the unknown tags.
|
||||
*
|
||||
* @knipignore - exported for tests
|
||||
*/
|
||||
export function getOrderedCustomSections(): OrderedCustomSections {
|
||||
const sectionData = getCustomSectionData();
|
||||
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
|
||||
const orderedSections: OrderedCustomSections = Array.isArray(rawValue) ? rawValue : [];
|
||||
return orderedSections.filter((tag) => tag in sectionData);
|
||||
const orderedSections = Array.isArray(rawValue) ? rawValue : [];
|
||||
return orderedSections.filter((tag): tag is CustomTag => isCustomSectionTag(tag) && tag in sectionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered list of reorderable section tags (custom sections + the Chats tag).
|
||||
* Favourite and LowPriority are not included — they are pinned at the top and bottom respectively.
|
||||
*
|
||||
* If `CHATS_TAG` is missing from the stored order (e.g. legacy data or a freshly created custom
|
||||
* section), it is appended at the end so that custom sections sit above Chats by default.
|
||||
*/
|
||||
export function getOrderedReorderableSections(): ReorderableSection[] {
|
||||
const sectionData = getCustomSectionData();
|
||||
const rawValue = SettingsStore.getValue("RoomList.OrderedCustomSections");
|
||||
const stored = Array.isArray(rawValue) ? rawValue : [];
|
||||
|
||||
const result = stored.filter((tag): tag is ReorderableSection => isReorderableSection(tag, sectionData));
|
||||
if (!result.includes(CHATS_TAG)) result.push(CHATS_TAG);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,10 +183,12 @@ export async function createSection(spaceId: SpaceKey): Promise<string | undefin
|
||||
sectionData[tag] = newSection;
|
||||
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
|
||||
|
||||
// Add the new section to the ordered list of sections
|
||||
const orderedSections = getOrderedCustomSections();
|
||||
orderedSections.push(tag);
|
||||
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
|
||||
// Add the new section to the ordered list of reorderable sections, just before CHATS_TAG
|
||||
// so that newly-created sections appear above Chats by default.
|
||||
const reorderable = getOrderedReorderableSections();
|
||||
const chatsIndex = reorderable.indexOf(CHATS_TAG);
|
||||
reorderable.splice(chatsIndex === -1 ? reorderable.length : chatsIndex, 0, tag);
|
||||
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, reorderable);
|
||||
return tag;
|
||||
}
|
||||
|
||||
@@ -204,11 +239,35 @@ export async function deleteSection(tag: string, isEmpty: boolean): Promise<void
|
||||
const [shouldRemoveSection] = await modal.finished;
|
||||
if (!shouldRemoveSection) return;
|
||||
|
||||
// Remove the section from the ordered list of sections
|
||||
const newOrderedSections = getOrderedCustomSections().filter((sectionTag) => sectionTag !== tag);
|
||||
// Remove the section from the ordered list of reorderable sections (preserves CHATS_TAG position)
|
||||
const newOrderedSections = getOrderedReorderableSections().filter((sectionTag) => sectionTag !== tag);
|
||||
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, newOrderedSections);
|
||||
|
||||
// Remove the section data
|
||||
delete sectionData[tag];
|
||||
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders sections by moving sourceTag relative to targetTag within the set of reorderable
|
||||
* sections (custom sections and the Chats tag). Favourite and LowPriority are not reorderable
|
||||
* and are rejected as either source or target.
|
||||
*
|
||||
* If the source was below the target, it is inserted before the target; otherwise after.
|
||||
* @param sourceTag - The tag of the section to move.
|
||||
* @param targetTag - The tag of the section to move relative to.
|
||||
*/
|
||||
export async function reorderSection(sourceTag: string, targetTag: string): Promise<void> {
|
||||
const ordered = getOrderedReorderableSections();
|
||||
const fromIndex = ordered.indexOf(sourceTag as ReorderableSection);
|
||||
|
||||
if (fromIndex === -1 || !ordered.includes(targetTag as ReorderableSection) || sourceTag === targetTag) return;
|
||||
|
||||
const toIndex = ordered.indexOf(targetTag as ReorderableSection);
|
||||
const insertBefore = fromIndex > toIndex;
|
||||
|
||||
ordered.splice(fromIndex, 1);
|
||||
const newToIndex = ordered.indexOf(targetTag as ReorderableSection);
|
||||
ordered.splice(insertBefore ? newToIndex : newToIndex + 1, 0, sourceTag as ReorderableSection);
|
||||
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, ordered);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import { NotificationStateEvents } from "../../stores/notifications/Notification
|
||||
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { getCustomSectionData, isCustomSectionTag, isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
getCustomSectionData,
|
||||
isCustomSectionTag,
|
||||
isDefaultSectionTag,
|
||||
} from "../../stores/room-list-v3/section";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
@@ -62,6 +67,7 @@ export class RoomListSectionHeaderViewModel
|
||||
isExpanded: true,
|
||||
isUnread: false,
|
||||
displaySectionMenu: !isDefaultSection,
|
||||
canBeReordered: !isDefaultSection || props.tag === CHATS_TAG,
|
||||
});
|
||||
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
|
||||
this.onCustomSectionDataChange(),
|
||||
|
||||
@@ -94,6 +94,11 @@ export class RoomListViewModel
|
||||
private roomsMap = new Map<string, Room>();
|
||||
// Don't clear section vm because we want to keep the expand/collapse state even during space changes.
|
||||
private readonly roomSectionHeaderViewModels = new Map<string, RoomListSectionHeaderViewModel>();
|
||||
/**
|
||||
* When dragging sections, we want to temporarily expand all sections to make it easier to move rooms between sections.
|
||||
* This map stores the original expansion state of each section before the drag starts, so we can restore it after the drag ends.
|
||||
*/
|
||||
private readonly savedExpansionStates = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Reference to the currently displayed toast, used to automatically close the toast after a timeout.
|
||||
@@ -663,6 +668,33 @@ export class RoomListViewModel
|
||||
}, 15 * 1000);
|
||||
}
|
||||
|
||||
public changeSectionOrder = async (sourceTag: string, targetTag: string): Promise<void> => {
|
||||
await RoomListStoreV3.instance.reorderSection(sourceTag, targetTag);
|
||||
// Scroll to the section after it moved
|
||||
const filterKeys = this.activeFilter !== undefined ? [this.activeFilter] : undefined;
|
||||
this.roomsResult = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filterKeys);
|
||||
this.updateRoomsMap(this.roomsResult);
|
||||
this.updateRoomListData(false, null, sourceTag);
|
||||
};
|
||||
|
||||
public onSectionDragStart = (): void => {
|
||||
this.savedExpansionStates.clear();
|
||||
for (const [tag, sectionVM] of this.roomSectionHeaderViewModels) {
|
||||
this.savedExpansionStates.set(tag, sectionVM.isExpanded);
|
||||
sectionVM.isExpanded = false;
|
||||
}
|
||||
this.updateRoomListData();
|
||||
};
|
||||
|
||||
public onSectionDragEnd = (): void => {
|
||||
for (const [tag, expanded] of this.savedExpansionStates) {
|
||||
const sectionVM = this.roomSectionHeaderViewModels.get(tag);
|
||||
if (sectionVM) sectionVM.isExpanded = expanded;
|
||||
}
|
||||
this.savedExpansionStates.clear();
|
||||
this.updateRoomListData();
|
||||
};
|
||||
|
||||
public changeRoomSection = (roomId: string, tag: string): void => {
|
||||
const room = this.props.client.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CHATS_TAG,
|
||||
CUSTOM_SECTION_TAG_PREFIX,
|
||||
isSectionTag,
|
||||
reorderSection,
|
||||
} from "../../../../src/stores/room-list-v3/section";
|
||||
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
|
||||
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
|
||||
@@ -328,13 +329,129 @@ describe("section", () => {
|
||||
await deleteSection(tag, false);
|
||||
|
||||
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
|
||||
expect(orderedCall![3]).toEqual([otherTag]);
|
||||
// CHATS_TAG is appended because the stored order didn't include it (legacy default position).
|
||||
expect(orderedCall![3]).toEqual([otherTag, CHATS_TAG]);
|
||||
|
||||
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
|
||||
expect(customDataCall![3]).not.toHaveProperty(tag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderSection", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc`;
|
||||
const customTag2 = `${CUSTOM_SECTION_TAG_PREFIX}def`;
|
||||
|
||||
function mockSettings(
|
||||
orderedTags: string[],
|
||||
customData: Record<string, { tag: string; name: string }> = {},
|
||||
): void {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "RoomList.OrderedCustomSections") return orderedTags;
|
||||
if (setting === "RoomList.CustomSectionData") return customData;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
it.each<{
|
||||
description: string;
|
||||
initial: string[];
|
||||
customData: Record<string, { tag: string; name: string }>;
|
||||
source: string;
|
||||
target: string;
|
||||
expected: string[];
|
||||
}>([
|
||||
{
|
||||
description: "a custom section after another custom section",
|
||||
initial: [customTag, customTag2],
|
||||
customData: {
|
||||
[customTag]: { tag: customTag, name: "A" },
|
||||
[customTag2]: { tag: customTag2, name: "B" },
|
||||
},
|
||||
source: customTag,
|
||||
target: customTag2,
|
||||
expected: [customTag2, customTag, CHATS_TAG],
|
||||
},
|
||||
{
|
||||
description: "a custom section before another when dragging up",
|
||||
initial: [customTag2, customTag],
|
||||
customData: {
|
||||
[customTag]: { tag: customTag, name: "A" },
|
||||
[customTag2]: { tag: customTag2, name: "B" },
|
||||
},
|
||||
source: customTag,
|
||||
target: customTag2,
|
||||
expected: [customTag, customTag2, CHATS_TAG],
|
||||
},
|
||||
{
|
||||
description: "a custom section past the Chats tag",
|
||||
initial: [customTag, customTag2, CHATS_TAG],
|
||||
customData: {
|
||||
[customTag]: { tag: customTag, name: "A" },
|
||||
[customTag2]: { tag: customTag2, name: "B" },
|
||||
},
|
||||
source: customTag,
|
||||
target: CHATS_TAG,
|
||||
expected: [customTag2, CHATS_TAG, customTag],
|
||||
},
|
||||
{
|
||||
description: "the Chats tag above a custom section",
|
||||
initial: [customTag, customTag2, CHATS_TAG],
|
||||
customData: {
|
||||
[customTag]: { tag: customTag, name: "A" },
|
||||
[customTag2]: { tag: customTag2, name: "B" },
|
||||
},
|
||||
source: CHATS_TAG,
|
||||
target: customTag,
|
||||
expected: [CHATS_TAG, customTag, customTag2],
|
||||
},
|
||||
])(
|
||||
"moves $description and saves the new order at ACCOUNT level",
|
||||
async ({ initial, customData, source, target, expected }) => {
|
||||
mockSettings(initial, customData);
|
||||
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
|
||||
await reorderSection(source, target);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
"RoomList.OrderedCustomSections",
|
||||
null,
|
||||
expect.anything(),
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: "source and target are the same",
|
||||
source: customTag,
|
||||
target: customTag,
|
||||
},
|
||||
{
|
||||
description: "source custom section is not in the ordered list",
|
||||
source: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
|
||||
target: customTag,
|
||||
},
|
||||
{
|
||||
description: "target custom section is not in the ordered list",
|
||||
source: customTag,
|
||||
target: `${CUSTOM_SECTION_TAG_PREFIX}unknown`,
|
||||
},
|
||||
{
|
||||
description: "source is a default section",
|
||||
source: DefaultTagID.Favourite,
|
||||
target: customTag,
|
||||
},
|
||||
])("does nothing when $description", async ({ source, target }) => {
|
||||
mockSettings([customTag], { [customTag]: { tag: customTag, name: "A" } });
|
||||
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
|
||||
|
||||
await reorderSection(source, target);
|
||||
|
||||
expect(setValueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefaultSectionTag", () => {
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
|
||||
expect(isDefaultSectionTag(tag)).toBe(true);
|
||||
|
||||
@@ -117,6 +117,23 @@ describe("RoomListSectionHeaderViewModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canBeReordered", () => {
|
||||
it.each([
|
||||
[DefaultTagID.Favourite, false],
|
||||
[DefaultTagID.LowPriority, false],
|
||||
[CHATS_TAG, true],
|
||||
["element.io.section.custom", true],
|
||||
])("should be %s for tag %s", (tag, expected) => {
|
||||
const vm = new RoomListSectionHeaderViewModel({
|
||||
tag,
|
||||
title: "Section",
|
||||
spaceId: "!space:server",
|
||||
onToggleExpanded,
|
||||
});
|
||||
expect(vm.getSnapshot().canBeReordered).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCustomSectionDataChange", () => {
|
||||
let watchCallback: () => void;
|
||||
|
||||
|
||||
@@ -1178,6 +1178,108 @@ describe("RoomListViewModel", () => {
|
||||
expect(snapshot.sections[0].roomIds[0]).toBe("!fav1:server");
|
||||
expect(snapshot.roomListState.activeRoomIndex).toBe(0);
|
||||
});
|
||||
|
||||
describe("Drag and drop", () => {
|
||||
beforeEach(() => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
// Ensure section header VMs are created before tests that interact with them
|
||||
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite);
|
||||
viewModel.getSectionHeaderViewModel(CHATS_TAG);
|
||||
viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority);
|
||||
});
|
||||
|
||||
it("should delegate changeSectionOrder to RoomListStoreV3.reorderSection", async () => {
|
||||
const reorderSpy = jest
|
||||
.spyOn(RoomListStoreV3.instance, "reorderSection")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
|
||||
|
||||
expect(reorderSpy).toHaveBeenCalledWith(DefaultTagID.Favourite, CHATS_TAG);
|
||||
});
|
||||
|
||||
it("should scroll the moved section back into view after reordering", async () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "reorderSection").mockResolvedValue(undefined);
|
||||
|
||||
await viewModel.changeSectionOrder(DefaultTagID.Favourite, CHATS_TAG);
|
||||
expect(viewModel.getSnapshot().roomListState.scrollToSectionTag).toBe(DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should collapse every section on drag start", () => {
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
|
||||
|
||||
viewModel.onSectionDragStart();
|
||||
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
|
||||
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(false);
|
||||
|
||||
for (const section of viewModel.getSnapshot().sections) {
|
||||
expect(section.roomIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should restore the pre-drag expansion state on drag end", () => {
|
||||
// Collapse Favourite before the drag; other sections remain expanded
|
||||
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
|
||||
|
||||
viewModel.onSectionDragStart();
|
||||
viewModel.onSectionDragEnd();
|
||||
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
|
||||
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
|
||||
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([]);
|
||||
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
|
||||
"!reg1:server",
|
||||
"!reg2:server",
|
||||
]);
|
||||
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
|
||||
"!low1:server",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should re-snapshot expansion state on each drag start", () => {
|
||||
// First cycle: Favourite is collapsed before the drag
|
||||
viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).onClick();
|
||||
viewModel.onSectionDragStart();
|
||||
viewModel.onSectionDragEnd();
|
||||
|
||||
// Between cycles: collapse CHATS_TAG as well
|
||||
viewModel.getSectionHeaderViewModel(CHATS_TAG).onClick();
|
||||
viewModel.onSectionDragStart();
|
||||
viewModel.onSectionDragEnd();
|
||||
|
||||
// The second drag end must restore the state captured at the second drag start
|
||||
// (Favourite collapsed, CHATS_TAG collapsed, LowPriority expanded), not the first cycle's snapshot.
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(false);
|
||||
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(false);
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("should be a no-op when drag end is called without drag start", () => {
|
||||
viewModel.onSectionDragEnd();
|
||||
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite).isExpanded).toBe(true);
|
||||
expect(viewModel.getSectionHeaderViewModel(CHATS_TAG).isExpanded).toBe(true);
|
||||
expect(viewModel.getSectionHeaderViewModel(DefaultTagID.LowPriority).isExpanded).toBe(true);
|
||||
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
expect(snapshot.sections.find((s) => s.id === DefaultTagID.Favourite)!.roomIds).toEqual([
|
||||
"!fav1:server",
|
||||
"!fav2:server",
|
||||
]);
|
||||
expect(snapshot.sections.find((s) => s.id === CHATS_TAG)!.roomIds).toEqual([
|
||||
"!reg1:server",
|
||||
"!reg2:server",
|
||||
]);
|
||||
expect(snapshot.sections.find((s) => s.id === DefaultTagID.LowPriority)!.roomIds).toEqual([
|
||||
"!low1:server",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -413,8 +413,16 @@ export function useVirtualizedList<Item, Context>(
|
||||
[handleRef],
|
||||
);
|
||||
|
||||
// Key items by id, not position, so react-virtuoso preserves (moves) the existing DOM
|
||||
// node when an item's absolute index shifts — e.g. sections collapsing on drag start removes
|
||||
// the rooms above a header, shifting its index. Without this, Virtuoso's default key is the
|
||||
// index, so the wrapper (and the focused header inside it) remounts, the roving-tabindex effect
|
||||
// refocuses the fresh node, and screen readers re-announce the header mid-drag.
|
||||
const computeItemKey = useCallback((_index: number, item: Item): string => getItemKey(item), [getItemKey]);
|
||||
|
||||
return {
|
||||
...virtuosoProps,
|
||||
computeItemKey,
|
||||
ref: setRef,
|
||||
scrollerRef,
|
||||
onKeyDown: keyDownCallback,
|
||||
|
||||
@@ -103,6 +103,17 @@
|
||||
"room_list": {
|
||||
"a11y": {
|
||||
"default": "Open room %(roomName)s",
|
||||
"drag_cancelled": "Dragging cancelled",
|
||||
"drag_end": "%(source)s was dropped on %(target)s",
|
||||
"drag_end_after": "%(source)s was dropped after %(target)s",
|
||||
"drag_end_before": "%(source)s was dropped before %(target)s",
|
||||
"drag_end_original": "%(source)s returned to its original position",
|
||||
"drag_instructions": "Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
|
||||
"drag_over": "%(source)s is over %(target)s",
|
||||
"drag_over_after": "%(source)s will be dropped after %(target)s",
|
||||
"drag_over_before": "%(source)s will be dropped before %(target)s",
|
||||
"drag_over_original": "%(source)s will return to its original position",
|
||||
"drag_start": "Dragging %(source)s",
|
||||
"invitation": "Open room %(roomName)s invitation.",
|
||||
"mention": {
|
||||
"one": "Open room %(roomName)s with 1 unread mention.",
|
||||
@@ -172,7 +183,7 @@
|
||||
"more_options": "More options",
|
||||
"remove_section": "Remove section",
|
||||
"toggle": "Toggle %(section)s section",
|
||||
"toggle_unread": "Toggle %(section)s section with unread room(s)"
|
||||
"toggle_unread": "Toggle %(section)s section with unread rooms"
|
||||
},
|
||||
"show_message_previews": "Show message previews",
|
||||
"sort": "Sort",
|
||||
|
||||
@@ -41,6 +41,9 @@ const RoomListViewWrapperImpl = ({
|
||||
renderAvatar: renderAvatarProp,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
changeSectionOrder,
|
||||
onSectionDragStart,
|
||||
onSectionDragEnd,
|
||||
...rest
|
||||
}: RoomListViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
@@ -52,6 +55,9 @@ const RoomListViewWrapperImpl = ({
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
changeSectionOrder,
|
||||
onSectionDragStart,
|
||||
onSectionDragEnd,
|
||||
});
|
||||
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
@@ -105,6 +111,9 @@ const meta = {
|
||||
toast: undefined,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
changeSectionOrder: fn(),
|
||||
onSectionDragStart: fn(),
|
||||
onSectionDragEnd: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
@@ -10,10 +10,16 @@ import { render, screen } from "@test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { VirtuosoMockContext } from "react-virtuoso";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListView.stories";
|
||||
|
||||
// Stable UUIDs so snapshots don't change between runs.
|
||||
let uuidCounter = 0;
|
||||
vi.spyOn(crypto, "randomUUID").mockImplementation(
|
||||
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, "0")}` as ReturnType<typeof crypto.randomUUID>,
|
||||
);
|
||||
|
||||
const {
|
||||
Default,
|
||||
Loading,
|
||||
|
||||
@@ -77,6 +77,12 @@ export interface RoomListViewActions {
|
||||
closeToast: () => void;
|
||||
/** Called to change the section of a room */
|
||||
changeRoomSection: (roomId: string, tag: string) => void;
|
||||
/** Called to change the order of sections */
|
||||
changeSectionOrder: (sourceTag: string, targetTag: string) => void;
|
||||
/** Called when a section drag starts — collapses all sections */
|
||||
onSectionDragStart: () => void;
|
||||
/** Called when a section drag ends (drop or cancel) — restores expansion states */
|
||||
onSectionDragEnd: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+169
-112
@@ -8428,42 +8428,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
aria-setsize="23"
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
|
||||
role="gridcell"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -8483,6 +8488,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room General"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -8640,6 +8646,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Random"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -8770,6 +8777,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Engineering"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -8906,6 +8914,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Design"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -9036,6 +9045,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Product"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -9172,6 +9182,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Marketing with 5 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -9325,6 +9336,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Sales"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -9461,6 +9473,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Support"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -9591,6 +9604,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Announcements"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -9727,6 +9741,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Off-topic"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -9857,6 +9872,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Team Alpha with 10 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10016,6 +10032,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Team Beta"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10146,6 +10163,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Project X"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -10282,6 +10300,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Project Y"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10412,6 +10431,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Water Cooler"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10548,6 +10568,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Feedback with 15 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -10701,6 +10722,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Ideas"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10837,6 +10859,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Bugs"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -10967,6 +10990,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Features"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -11103,6 +11127,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Releases"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -11233,6 +11258,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room General with 20 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -11392,6 +11418,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Random"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -11522,6 +11549,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Engineering"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -11655,42 +11683,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
aria-setsize="29"
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Chats section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
role="gridcell"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Chats section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -11710,6 +11743,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Design"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -11840,6 +11874,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Product"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -11976,6 +12011,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Marketing with 25 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -12129,6 +12165,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Sales"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -12265,6 +12302,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Support"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -12395,6 +12433,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Announcements"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -12531,6 +12570,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Off-topic"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -12661,6 +12701,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Team Alpha with 30 unread mentions."
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -12820,6 +12861,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Team Beta"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -12950,6 +12992,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Project X"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -13086,6 +13129,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Project Y"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -13216,6 +13260,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000006"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Water Cooler"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -13880,42 +13925,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
|
||||
aria-setsize="2"
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
|
||||
role="gridcell"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000003"
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -13935,6 +13985,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000003"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room General"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
|
||||
@@ -14092,6 +14143,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
|
||||
role="gridcell"
|
||||
>
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000003"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Open room Random"
|
||||
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
|
||||
@@ -14219,42 +14271,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
|
||||
aria-setsize="0"
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Chats section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
role="gridcell"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
<button
|
||||
aria-describedby="00000000-0000-0000-0000-000000000003"
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Chats section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@test-utils";
|
||||
|
||||
import {
|
||||
type A11yData,
|
||||
type DragAnnouncementGetter,
|
||||
RoomListAccessibilityPlugin,
|
||||
type RoomListAccessibilityOptions,
|
||||
useRoomListAccessibilityPlugin,
|
||||
} from "./RoomListAccessibilityPlugin";
|
||||
import { I18nContext } from "../../core/i18n/i18nContext";
|
||||
import { I18nApi } from "../../core/i18n/I18nApi";
|
||||
import type { RoomListViewModel } from "../RoomListView";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal mock manager compatible with the @dnd-kit/abstract Plugin base class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EventHandler = (event: unknown) => void;
|
||||
|
||||
function createMockManager(): {
|
||||
monitor: { addEventListener: ReturnType<typeof vi.fn> };
|
||||
registry: {
|
||||
draggables: { readonly value: IterableIterator<{ handle: HTMLElement | null; element: HTMLElement | null }> };
|
||||
};
|
||||
dispatch: (eventName: string, event: unknown) => void;
|
||||
draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[];
|
||||
} {
|
||||
const listeners = new Map<string, EventHandler[]>();
|
||||
|
||||
const monitor = {
|
||||
addEventListener: vi.fn((eventName: string, handler: EventHandler) => {
|
||||
if (!listeners.has(eventName)) listeners.set(eventName, []);
|
||||
listeners.get(eventName)!.push(handler);
|
||||
return vi.fn(() => {
|
||||
const fns = listeners.get(eventName);
|
||||
if (fns) {
|
||||
const idx = fns.indexOf(handler);
|
||||
if (idx >= 0) fns.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
// A list of fake draggable objects the effect iterates over.
|
||||
const draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[] = [];
|
||||
|
||||
const registry = {
|
||||
draggables: {
|
||||
// Plain (non-reactive) getter – the effect runs once on construction.
|
||||
get value() {
|
||||
return draggableElements.values();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Trigger a monitor event on all registered handlers. */
|
||||
const dispatch = (eventName: string, event: unknown): void => {
|
||||
listeners.get(eventName)?.forEach((fn) => fn(event));
|
||||
};
|
||||
|
||||
return { monitor, registry, dispatch, draggableElements };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createPlugin(
|
||||
manager: ReturnType<typeof createMockManager>,
|
||||
options?: RoomListAccessibilityOptions,
|
||||
): RoomListAccessibilityPlugin {
|
||||
// RoomListAccessibilityPlugin extends Plugin<DragDropManager> from @dnd-kit/abstract.
|
||||
// The base class only requires manager.monitor and manager.registry to exist, which our
|
||||
// mock satisfies.
|
||||
return new RoomListAccessibilityPlugin(manager as never, options);
|
||||
}
|
||||
|
||||
function getLiveRegion(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>("[role='status'][aria-live='polite']");
|
||||
}
|
||||
|
||||
function getAssertiveRegion(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>("[role='alert'][aria-live='assertive']");
|
||||
}
|
||||
|
||||
function getInstructions(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>("[style*='display: none']");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RoomListAccessibilityPlugin", () => {
|
||||
let manager: ReturnType<typeof createMockManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = createMockManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any DOM nodes left behind by tests that don't call destroy().
|
||||
getLiveRegion()?.remove();
|
||||
getAssertiveRegion()?.remove();
|
||||
getInstructions()?.remove();
|
||||
});
|
||||
|
||||
describe("construction", () => {
|
||||
it("appends a polite aria-live region to the document body", () => {
|
||||
const plugin = createPlugin(manager);
|
||||
|
||||
const region = getLiveRegion();
|
||||
expect(region).not.toBeNull();
|
||||
expect(region).toBeInTheDocument();
|
||||
expect(region).toHaveAttribute("role", "status");
|
||||
expect(region).toHaveAttribute("aria-live", "polite");
|
||||
expect(region).toHaveAttribute("aria-atomic", "true");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("appends an assertive aria-live region to the document body", () => {
|
||||
const plugin = createPlugin(manager);
|
||||
|
||||
const region = getAssertiveRegion();
|
||||
expect(region).not.toBeNull();
|
||||
expect(region).toBeInTheDocument();
|
||||
expect(region).toHaveAttribute("role", "alert");
|
||||
expect(region).toHaveAttribute("aria-live", "assertive");
|
||||
expect(region).toHaveAttribute("aria-atomic", "true");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("appends a hidden instructions element when the instructions option is provided", () => {
|
||||
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
|
||||
|
||||
const el = getInstructions();
|
||||
expect(el).not.toBeNull();
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(el?.textContent).toBe("Press Space to drag");
|
||||
expect(el?.style.display).toBe("none");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("does not append an instructions element when the option is omitted", () => {
|
||||
const plugin = createPlugin(manager);
|
||||
expect(getInstructions()).toBeNull();
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("adds aria-describedby pointing to the instructions element on existing draggables", () => {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("aria-label", "Toggle Favourites section");
|
||||
document.body.append(button);
|
||||
|
||||
manager.draggableElements.push({ handle: button, element: button });
|
||||
|
||||
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
|
||||
|
||||
// The ID is a generated UUID — verify the button points to the instructions element.
|
||||
const instructionsId = button.getAttribute("aria-describedby");
|
||||
expect(instructionsId).toBeTruthy();
|
||||
expect(document.getElementById(instructionsId!)).toBe(getInstructions());
|
||||
|
||||
button.remove();
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("does not overwrite an existing aria-describedby on a draggable", () => {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("aria-describedby", "my-existing-id");
|
||||
document.body.append(button);
|
||||
|
||||
manager.draggableElements.push({ handle: button, element: button });
|
||||
|
||||
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
|
||||
|
||||
expect(button).toHaveAttribute("aria-describedby", "my-existing-id");
|
||||
|
||||
button.remove();
|
||||
plugin.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dragstart and dragover announcements", () => {
|
||||
it("writes a dragstart message to the live region", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragstart: () => "Dragging Favourites" },
|
||||
});
|
||||
|
||||
manager.dispatch("dragstart", {});
|
||||
|
||||
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("writes a dragover message to the live region", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragover: () => "Dragging Favourites over Low Priority" },
|
||||
});
|
||||
|
||||
manager.dispatch("dragover", {});
|
||||
|
||||
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites over Low Priority");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("does not update the live region when the getter returns undefined", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragstart: () => undefined },
|
||||
});
|
||||
|
||||
manager.dispatch("dragstart", {});
|
||||
|
||||
expect(getLiveRegion()?.textContent).toBe("");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("does not update the live region when the message is the same as the current text", () => {
|
||||
const get = vi.fn(() => "Dragging Favourites");
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragstart: get },
|
||||
});
|
||||
|
||||
manager.dispatch("dragstart", {});
|
||||
manager.dispatch("dragstart", {});
|
||||
|
||||
// The getter was called twice but the live region text is set only once (dedup).
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("passes the raw dnd-kit event to the announcement getter", () => {
|
||||
const getter = vi.fn(() => "Dragging Favourites");
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragstart: getter },
|
||||
});
|
||||
|
||||
const fakeEvent = { operation: { source: { id: "fav" } } };
|
||||
manager.dispatch("dragstart", fakeEvent);
|
||||
|
||||
expect(getter).toHaveBeenCalledWith(fakeEvent);
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dragend announcement", () => {
|
||||
it("announces the drop message in the assertive live region", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragend: () => "Favourites was dropped on Low Priority" },
|
||||
});
|
||||
|
||||
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
|
||||
|
||||
// The drop is announced in the assertive region: focus stays on the source element so
|
||||
// there is no focus change to read, and an assertive region reliably announces on Chrome.
|
||||
expect(getAssertiveRegion()?.textContent).toBe("Favourites was dropped on Low Priority");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("re-announces an identical message by clearing the region first", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragend: () => "Dropped" },
|
||||
});
|
||||
|
||||
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
|
||||
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
|
||||
|
||||
// The same text set twice must still end up in the region (clear-then-set forces a change).
|
||||
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
|
||||
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
|
||||
it("does not announce when the getter returns undefined", () => {
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragend: () => undefined },
|
||||
});
|
||||
|
||||
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
|
||||
|
||||
// Both live regions stay empty.
|
||||
expect(getLiveRegion()?.textContent).toBe("");
|
||||
expect(getAssertiveRegion()?.textContent).toBe("");
|
||||
|
||||
plugin.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("removes the live region from the document", () => {
|
||||
const plugin = createPlugin(manager);
|
||||
expect(getLiveRegion()).not.toBeNull();
|
||||
|
||||
plugin.destroy();
|
||||
|
||||
expect(getLiveRegion()).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the assertive live region from the document", () => {
|
||||
const plugin = createPlugin(manager);
|
||||
expect(getAssertiveRegion()).not.toBeNull();
|
||||
|
||||
plugin.destroy();
|
||||
|
||||
expect(getAssertiveRegion()).toBeNull();
|
||||
});
|
||||
|
||||
it("removes the instructions element from the document", () => {
|
||||
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
|
||||
expect(getInstructions()).not.toBeNull();
|
||||
|
||||
plugin.destroy();
|
||||
|
||||
expect(getInstructions()).toBeNull();
|
||||
});
|
||||
|
||||
it("calls the unsubscribe functions returned by monitor.addEventListener", () => {
|
||||
// Capture the unsubscribe function spy that the mock returns.
|
||||
let unsubscribeSpy: ReturnType<typeof vi.fn> | undefined;
|
||||
manager.monitor.addEventListener.mockImplementation((_eventName: string, _handler: EventHandler) => {
|
||||
unsubscribeSpy = vi.fn();
|
||||
return unsubscribeSpy as unknown as ReturnType<typeof vi.fn<() => void>>;
|
||||
});
|
||||
|
||||
const plugin = createPlugin(manager, {
|
||||
announcements: { dragstart: () => "Dragging" },
|
||||
});
|
||||
|
||||
plugin.destroy();
|
||||
|
||||
expect(unsubscribeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRoomListAccessibilityPlugin announcements", () => {
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
work: "Work",
|
||||
fun: "Fun",
|
||||
};
|
||||
const ROOM_NAMES: Record<string, string> = {
|
||||
"!room:server": "My Room",
|
||||
};
|
||||
|
||||
function createMockVm(): RoomListViewModel {
|
||||
return {
|
||||
getSectionHeaderViewModel: (id: string) => ({
|
||||
getSnapshot: () => ({ title: SECTION_TITLES[id] ?? id }),
|
||||
}),
|
||||
getRoomItemViewModel: (id: string) => ({
|
||||
getSnapshot: () => ({ name: ROOM_NAMES[id] }),
|
||||
}),
|
||||
} as unknown as RoomListViewModel;
|
||||
}
|
||||
|
||||
/** Render the hook and return the announcement getters it configures on the plugin. */
|
||||
function getAnnouncements(
|
||||
vm: RoomListViewModel,
|
||||
): Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>> {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }): React.ReactNode =>
|
||||
React.createElement(I18nContext.Provider, { value: new I18nApi() }, children);
|
||||
const { result } = renderHook(() => useRoomListAccessibilityPlugin(vm), { wrapper });
|
||||
|
||||
const descriptor = result.current([]).find(
|
||||
(
|
||||
plugin,
|
||||
): plugin is {
|
||||
plugin: typeof RoomListAccessibilityPlugin;
|
||||
options: RoomListAccessibilityOptions;
|
||||
} => typeof plugin === "object" && plugin.plugin === RoomListAccessibilityPlugin,
|
||||
);
|
||||
return descriptor!.options.announcements!;
|
||||
}
|
||||
|
||||
const sectionSource = (id: string, index: number): A11yData["operation"]["source"] =>
|
||||
({ id, data: { type: "section", index } }) as A11yData["operation"]["source"];
|
||||
const sectionTarget = (id: string, index: number): A11yData["operation"]["target"] =>
|
||||
({ id, data: { type: "section", index } }) as A11yData["operation"]["target"];
|
||||
|
||||
it("announces a section will return to its original position when dragged over a non-droppable area", () => {
|
||||
const { dragover } = getAnnouncements(createMockVm());
|
||||
const message = dragover!({
|
||||
operation: { source: sectionSource("work", 1), target: null },
|
||||
canceled: false,
|
||||
});
|
||||
expect(message).toBe("Work will return to its original position");
|
||||
});
|
||||
|
||||
it("announces a section returned to its original position when dropped on a non-droppable area", () => {
|
||||
const { dragend } = getAnnouncements(createMockVm());
|
||||
const message = dragend!({
|
||||
operation: { source: sectionSource("work", 1), target: null },
|
||||
canceled: false,
|
||||
});
|
||||
expect(message).toBe("Work returned to its original position");
|
||||
});
|
||||
|
||||
it("still announces the before/after target when a section is dragged over another section", () => {
|
||||
const { dragover, dragend } = getAnnouncements(createMockVm());
|
||||
// Source index 2 dropped onto target index 1 → dropped before the target.
|
||||
const event: A11yData = {
|
||||
operation: { source: sectionSource("fun", 2), target: sectionTarget("work", 1) },
|
||||
canceled: false,
|
||||
};
|
||||
expect(dragover!(event)).toBe("Fun will be dropped before Work");
|
||||
expect(dragend!(event)).toBe("Fun was dropped before Work");
|
||||
});
|
||||
|
||||
it("announces cancellation even when there is no target", () => {
|
||||
const { dragend } = getAnnouncements(createMockVm());
|
||||
const message = dragend!({
|
||||
operation: { source: sectionSource("work", 1), target: null },
|
||||
canceled: true,
|
||||
});
|
||||
expect(message).toBe("Dragging cancelled");
|
||||
});
|
||||
});
|
||||
});
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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 { useCallback, useMemo } from "react";
|
||||
import { configure, Plugin, type Plugins } from "@dnd-kit/abstract";
|
||||
import { Accessibility, type Draggable, type DragDropManager, type Droppable } from "@dnd-kit/dom";
|
||||
|
||||
import { useI18n } from "../../core/i18n/i18nContext";
|
||||
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
|
||||
import type { RoomListViewModel } from "../RoomListView";
|
||||
|
||||
type Manager = DragDropManager;
|
||||
|
||||
/** Shape of the dnd-kit monitor event passed to the announcement getters. */
|
||||
export type A11yData = {
|
||||
operation: {
|
||||
source: Draggable<RoomListDragData> | null;
|
||||
target: Droppable<RoomListDragData> | null;
|
||||
};
|
||||
canceled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces the screen-reader announcement for a drag lifecycle event, or `undefined`
|
||||
* to stay silent. The event exposes `operation.source`, `operation.target` and (on
|
||||
* `dragend`) `canceled`.
|
||||
*/
|
||||
export type DragAnnouncementGetter = (event: A11yData) => string | undefined;
|
||||
|
||||
/**
|
||||
* Options for {@link RoomListAccessibilityPlugin}.
|
||||
*
|
||||
* All fields are optional so the options type stays assignable to dnd-kit's generic
|
||||
* `PluginOptions`.
|
||||
*/
|
||||
export interface RoomListAccessibilityOptions {
|
||||
/** Announcement to emit for each drag lifecycle event. */
|
||||
announcements?: Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>>;
|
||||
/**
|
||||
* Keyboard drag instructions read out when a draggable receives focus, wired to each
|
||||
* draggable via `aria-describedby`.
|
||||
*/
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the visually-hidden `aria-live` region used to announce drag progress.
|
||||
*
|
||||
* @param politeness - `"polite"` for progress (start/over) updates, `"assertive"` for the terminal
|
||||
* drop/cancel confirmation so it interrupts any pending progress chatter and is announced reliably.
|
||||
*/
|
||||
function createLiveRegion(id: string, politeness: "polite" | "assertive" = "polite"): HTMLDivElement {
|
||||
const element = document.createElement("div");
|
||||
element.id = id;
|
||||
element.setAttribute("role", politeness === "assertive" ? "alert" : "status");
|
||||
element.setAttribute("aria-live", politeness);
|
||||
element.setAttribute("aria-atomic", "true");
|
||||
Object.assign(element.style, {
|
||||
position: "fixed",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
border: "0",
|
||||
padding: "0",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(100%)",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the hidden element holding the keyboard drag instructions. Only referenced via
|
||||
* `aria-describedby` (never announced), so `display: none` is enough to hide it.
|
||||
*/
|
||||
function createInstructions(id: string, text: string): HTMLDivElement {
|
||||
const element = document.createElement("div");
|
||||
element.id = id;
|
||||
element.style.display = "none";
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dnd-kit plugin that manages the room list's drag-and-drop accessibility:
|
||||
* - announces drag progress to screen readers via an `aria-live` region, and
|
||||
* - exposes the keyboard drag instructions, wiring them to every draggable through
|
||||
* `aria-describedby`.
|
||||
*
|
||||
* This is a deliberately reduced replacement for dnd-kit's built-in `Accessibility`
|
||||
* plugin. The built-in plugin also reflects `aria-pressed`/`aria-grabbed` onto the
|
||||
* draggable `<button>`, which VoiceOver reads as "selected" the instant a keyboard drag
|
||||
* starts. We filter the built-in plugin out (see VirtualizedRoomListView) and use this
|
||||
* instead: it never mutates the draggable's pressed/grabbed ARIA state.
|
||||
*/
|
||||
export class RoomListAccessibilityPlugin extends Plugin<Manager, RoomListAccessibilityOptions> {
|
||||
private liveRegion: HTMLDivElement;
|
||||
private assertiveRegion: HTMLDivElement;
|
||||
private instructions?: HTMLDivElement;
|
||||
private readonly unsubscribers: Array<() => void> = [];
|
||||
|
||||
public constructor(manager: Manager, options?: RoomListAccessibilityOptions) {
|
||||
super(manager, options);
|
||||
|
||||
const liveRegionId = crypto.randomUUID();
|
||||
const assertiveRegionId = crypto.randomUUID();
|
||||
const instructionsId = crypto.randomUUID();
|
||||
|
||||
// Create the live regions up front so they exist in the DOM before any text change,
|
||||
// which assistive technologies require to reliably announce the first message.
|
||||
this.liveRegion = createLiveRegion(liveRegionId);
|
||||
this.assertiveRegion = createLiveRegion(assertiveRegionId, "assertive");
|
||||
document.body.append(this.liveRegion, this.assertiveRegion);
|
||||
|
||||
const announcements = options?.announcements ?? {};
|
||||
for (const [eventName, getAnnouncement] of Object.entries(announcements)) {
|
||||
if (!getAnnouncement) continue;
|
||||
let unsubscribe: () => void;
|
||||
if (eventName === "dragend") {
|
||||
unsubscribe = manager.monitor.addEventListener("dragend", (event) => {
|
||||
const a11yEvent = event as unknown as A11yData;
|
||||
const message = getAnnouncement(a11yEvent);
|
||||
// On drop the source element stays focused (focus never actually moves), so we need to announce the drop confirmation in the assertive region to ensure it is read.
|
||||
this.announceAssertive(message);
|
||||
});
|
||||
} else {
|
||||
unsubscribe = manager.monitor.addEventListener(eventName as "dragstart", (event) => {
|
||||
this.announce(getAnnouncement(event as unknown as A11yData));
|
||||
});
|
||||
}
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
|
||||
if (options?.instructions) {
|
||||
this.instructions = createInstructions(instructionsId, options.instructions);
|
||||
document.body.append(this.instructions);
|
||||
|
||||
// Point every draggable at the instructions via aria-describedby. The effect re-runs
|
||||
// as draggables register/unregister (the list is virtualized), so newly mounted items
|
||||
// get described too. We never touch aria-pressed/aria-grabbed here.
|
||||
this.registerEffect(() => {
|
||||
if (!this.instructions) return;
|
||||
for (const draggable of this.manager.registry.draggables.value) {
|
||||
const activator = draggable.handle ?? draggable.element;
|
||||
if (activator && !activator.hasAttribute("aria-describedby")) {
|
||||
activator.setAttribute("aria-describedby", instructionsId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private announce(message: string | undefined): void {
|
||||
if (!message || this.liveRegion.textContent === message) return;
|
||||
this.liveRegion.textContent = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a terminal message (drop confirmation / cancellation) in the assertive live region.
|
||||
*
|
||||
* Unlike the polite progress region, the drop happens with focus parked on the source element
|
||||
* and no focus change to trigger a re-read, so an assertive region is required for Chrome to
|
||||
* announce it. It also interrupts any still-pending "… will be dropped …" progress chatter.
|
||||
*/
|
||||
private announceAssertive(message: string | undefined): void {
|
||||
if (!message) return;
|
||||
// Always re-set (clearing first) so an identical message still re-announces, and so the
|
||||
// assertive region reliably fires even right after a polite progress update.
|
||||
this.assertiveRegion.textContent = "";
|
||||
this.assertiveRegion.textContent = message;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
super.destroy();
|
||||
for (const unsubscribe of this.unsubscribers) unsubscribe();
|
||||
this.unsubscribers.length = 0;
|
||||
this.liveRegion.remove();
|
||||
this.assertiveRegion.remove();
|
||||
this.instructions?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures {@link RoomListAccessibilityPlugin} for the room list and returns a `plugins`
|
||||
* callback for `DragDropProvider`.
|
||||
*
|
||||
* It swaps dnd-kit's built-in Accessibility plugin (which adds the `aria-pressed` that
|
||||
* VoiceOver reads as "selected") for {@link RoomListAccessibilityPlugin}, supplying it with
|
||||
* localized announcements derived from the room list view model. The result is memoized so
|
||||
* the plugin descriptor stays stable across renders and the plugin isn't torn down and
|
||||
* recreated.
|
||||
*/
|
||||
export function useRoomListAccessibilityPlugin(
|
||||
vm: RoomListViewModel,
|
||||
): (defaults: Plugins<Manager>) => Plugins<Manager> {
|
||||
const { translate: _t } = useI18n();
|
||||
|
||||
// Get the display name of a draggable source: the section title for a section, or the
|
||||
// room name for a room. Returns undefined if the source can't be resolved.
|
||||
const getDragSourceName = useCallback(
|
||||
(source: Draggable<RoomListDragData>): string | undefined => {
|
||||
if (isSectionDragData(source.data)) {
|
||||
return vm.getSectionHeaderViewModel(source.id as string).getSnapshot().title;
|
||||
}
|
||||
return vm.getRoomItemViewModel(source.id as string)?.getSnapshot().name;
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
|
||||
const announcements = useMemo(
|
||||
() => ({
|
||||
dragstart: ({ operation: { source } }: A11yData) => {
|
||||
if (!source) return;
|
||||
const sourceName = getDragSourceName(source);
|
||||
if (sourceName === undefined) return;
|
||||
return _t("room_list|a11y|drag_start", { source: sourceName });
|
||||
},
|
||||
dragover: ({ operation: { source, target } }: A11yData) => {
|
||||
if (!source) return;
|
||||
const sourceName = getDragSourceName(source);
|
||||
if (sourceName === undefined) return;
|
||||
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
|
||||
if (isSectionDragData(source.data) && !target) {
|
||||
return _t("room_list|a11y|drag_over_original", { source: sourceName });
|
||||
}
|
||||
if (!target) return;
|
||||
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
|
||||
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
|
||||
const droppedBefore = source.data.index > target.data.index;
|
||||
|
||||
return droppedBefore
|
||||
? _t("room_list|a11y|drag_over_before", { source: sourceName, target: targetTitle })
|
||||
: _t("room_list|a11y|drag_over_after", { source: sourceName, target: targetTitle });
|
||||
}
|
||||
return _t("room_list|a11y|drag_over", { source: sourceName, target: targetTitle });
|
||||
},
|
||||
dragend: ({ operation: { source, target }, canceled }: A11yData) => {
|
||||
if (!source) return;
|
||||
if (canceled) return _t("room_list|a11y|drag_cancelled");
|
||||
const sourceName = getDragSourceName(source);
|
||||
if (sourceName === undefined) return;
|
||||
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
|
||||
if (isSectionDragData(source.data) && !target) {
|
||||
return _t("room_list|a11y|drag_end_original", { source: sourceName });
|
||||
}
|
||||
|
||||
if (!target) return;
|
||||
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
|
||||
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
|
||||
const droppedBefore = source.data.index > target.data.index;
|
||||
return droppedBefore
|
||||
? _t("room_list|a11y|drag_end_before", { source: sourceName, target: targetTitle })
|
||||
: _t("room_list|a11y|drag_end_after", { source: sourceName, target: targetTitle });
|
||||
}
|
||||
return _t("room_list|a11y|drag_end", { source: sourceName, target: targetTitle });
|
||||
},
|
||||
}),
|
||||
[vm, _t, getDragSourceName],
|
||||
);
|
||||
|
||||
const instructions = _t("room_list|a11y|drag_instructions");
|
||||
|
||||
return useCallback(
|
||||
(defaults) => [
|
||||
// remove the built-in Accessibility plugin
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
configure(RoomListAccessibilityPlugin, { announcements, instructions }),
|
||||
],
|
||||
[announcements, instructions],
|
||||
);
|
||||
}
|
||||
+4
@@ -35,7 +35,11 @@ export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlay
|
||||
renderAvatar,
|
||||
}: RoomListItemDragOverlayViewProps): JSX.Element {
|
||||
return (
|
||||
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
|
||||
// dragged room isn't duplicated (the real, still-focused item already exposes it, and drag
|
||||
// feedback is narrated via the dnd live-region announcements).
|
||||
<Flex
|
||||
aria-hidden={true}
|
||||
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="stretch"
|
||||
|
||||
+3
-1
@@ -14,6 +14,7 @@ import { useMergeRefs } from "react-merge-refs";
|
||||
import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
|
||||
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
|
||||
import { useViewModel } from "../../../core/viewmodel";
|
||||
import { type RoomDragData } from "../dragAndDrop";
|
||||
|
||||
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
|
||||
/** Index of this room in the list */
|
||||
@@ -76,8 +77,9 @@ function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
isDragSource,
|
||||
} = useDraggable({
|
||||
} = useDraggable<RoomDragData>({
|
||||
id: item.id,
|
||||
data: { type: "room" },
|
||||
// We clone the item in the dnd overlay to avoid putting a hole in the list
|
||||
plugins: [Feedback.configure({ feedback: "clone" })],
|
||||
modifiers: [RestrictToVerticalAxis],
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
type RoomListSectionHeaderViewSnapshot,
|
||||
type RoomListSectionHeaderActions,
|
||||
} from "../RoomListSectionHeaderView";
|
||||
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
|
||||
import { useMockedViewModel } from "../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../.storybook/withViewDocs";
|
||||
|
||||
type RoomListSectionHeaderDragOverlayProps = RoomListSectionHeaderViewSnapshot & RoomListSectionHeaderActions;
|
||||
|
||||
const RoomListSectionHeaderDragOverlayWrapperImpl = ({
|
||||
onClick,
|
||||
editSection,
|
||||
removeSection,
|
||||
...rest
|
||||
}: RoomListSectionHeaderDragOverlayProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, { onClick, editSection, removeSection });
|
||||
return <RoomListSectionHeaderDragOverlayView vm={vm} />;
|
||||
};
|
||||
const RoomListSectionHeaderDragOverlayWrapper = withViewDocs(
|
||||
RoomListSectionHeaderDragOverlayWrapperImpl,
|
||||
RoomListSectionHeaderDragOverlayView,
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListSectionHeaderDragOverlayView",
|
||||
component: RoomListSectionHeaderDragOverlayWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px", padding: "8px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
id: "element.io.section.abc123",
|
||||
title: "Work",
|
||||
isExpanded: true,
|
||||
isUnread: false,
|
||||
displaySectionMenu: true,
|
||||
canBeReordered: true,
|
||||
onClick: fn(),
|
||||
editSection: fn(),
|
||||
removeSection: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof RoomListSectionHeaderDragOverlayWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListSectionHeaderDragOverlayView.stories";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("<RoomListSectionHeaderDragOverlayView /> stories", () => {
|
||||
it("renders Default story", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { memo, type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView";
|
||||
import { RoomListSectionHeaderContent } from "../RoomListSectionHeaderView/RoomListSectionHeaderContent";
|
||||
import headerStyles from "../RoomListSectionHeaderView/RoomListSectionHeaderView.module.css";
|
||||
import styles from "./RoomListSectionHeaderDragOverlayView.module.css";
|
||||
|
||||
/**
|
||||
* Props for {@link RoomListSectionHeaderDragOverlayView}.
|
||||
*/
|
||||
export interface RoomListSectionHeaderDragOverlayViewProps {
|
||||
/** The section header view model — same one used by the real section header */
|
||||
vm: RoomListSectionHeaderViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual clone of a section header rendered inside the dnd drag overlay.
|
||||
*
|
||||
* Reuses {@link RoomListSectionHeaderContent} for the inner layout so the
|
||||
* floating clone matches a real section header.
|
||||
*/
|
||||
export const RoomListSectionHeaderDragOverlayView = memo(function RoomListSectionHeaderDragOverlayView({
|
||||
vm,
|
||||
}: RoomListSectionHeaderDragOverlayViewProps): JSX.Element {
|
||||
return (
|
||||
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
|
||||
// dragged section's title isn't duplicated (the real, still-focused header already exposes
|
||||
// it, and drag feedback is narrated via the dnd live-region announcements).
|
||||
<div aria-hidden={true} className={classNames(headerStyles.header, styles.dragOverlay)}>
|
||||
<RoomListSectionHeaderContent vm={vm} isDragging={true} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<RoomListSectionHeaderDragOverlayView /> stories > renders Default story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
style="width: 320px; padding: 8px;"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderDragOverlayView-module_dragOverlay"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container RoomListSectionHeaderView-module_dragging"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Work
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
|
||||
export type { RoomListSectionHeaderDragOverlayViewProps } from "./RoomListSectionHeaderDragOverlayView";
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { memo, type JSX, useState } from "react";
|
||||
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
|
||||
import classNames from "classnames";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useViewModel } from "../../../core/viewmodel";
|
||||
import { _t } from "../../../core/i18n/i18n";
|
||||
import { Flex } from "../../../core/utils/Flex";
|
||||
import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
|
||||
import styles from "./RoomListSectionHeaderView.module.css";
|
||||
import { NotificationDecoration } from "../RoomListItemWrapper/RoomListItemView";
|
||||
|
||||
/**
|
||||
* Props for {@link RoomListSectionHeaderContent}.
|
||||
*/
|
||||
export interface RoomListSectionHeaderContentProps {
|
||||
/** The section header view model */
|
||||
vm: RoomListSectionHeaderViewModel;
|
||||
/** Whether the section header is being dragged — hides the interactive menu when true */
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner content of a section header: chevron, title, and menu (or static menu icon when dragging).
|
||||
* Used both inside the full {@link RoomListSectionHeaderView} and inside the drag overlay.
|
||||
*/
|
||||
export const RoomListSectionHeaderContent = memo(function RoomListSectionHeaderContent({
|
||||
vm,
|
||||
isDragging = false,
|
||||
}: RoomListSectionHeaderContentProps): JSX.Element {
|
||||
const { title, displaySectionMenu, notification, isExpanded } = useViewModel(vm);
|
||||
return (
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dragging]: isDragging,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<Flex align="center" gap="var(--cpd-space-0-5x)">
|
||||
<ChevronRightIcon
|
||||
className={styles.chevron}
|
||||
width="24px"
|
||||
height="24px"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
/>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</Flex>
|
||||
{!isExpanded && notification && (
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...notification} />
|
||||
</div>
|
||||
)}
|
||||
{displaySectionMenu && !isDragging && <MenuComponent vm={vm} />}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
interface MenuComponentProps {
|
||||
vm: RoomListSectionHeaderViewModel;
|
||||
}
|
||||
|
||||
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|section_header|more_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
className={styles.menu}
|
||||
tooltip={_t("room_list|section_header|more_options")}
|
||||
aria-label={_t("room_list|section_header|more_options")}
|
||||
size="24px"
|
||||
style={{ padding: "2px" }}
|
||||
color="var(--cpd-color-icon-primary)"
|
||||
>
|
||||
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
hideChevron={true}
|
||||
Icon={EditIcon}
|
||||
label={_t("room_list|section_header|edit_section")}
|
||||
onSelect={() => vm.editSection()}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
<MenuItem
|
||||
hideChevron={true}
|
||||
Icon={DeleteIcon}
|
||||
label={_t("room_list|section_header|remove_section")}
|
||||
onSelect={() => vm.removeSection()}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+30
-1
@@ -65,6 +65,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
margin: 0 var(--cpd-space-3x);
|
||||
padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x);
|
||||
border-radius: 8px;
|
||||
@@ -99,10 +100,38 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
.dropTarget .container {
|
||||
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
|
||||
}
|
||||
|
||||
.dropTargetBottom .container::after,
|
||||
.dropTargetTop .container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--cpd-color-border-accent-primary);
|
||||
}
|
||||
|
||||
.dropTargetBottom .container::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.dropTargetTop .container::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
outline: 1px solid var(--cpd-color-border-interactive-hovered);
|
||||
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 95%, transparent);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
.dragSource .container {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
+14
-3
@@ -7,6 +7,8 @@
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import { DragDropProvider } from "@dnd-kit/react";
|
||||
import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
@@ -71,6 +73,7 @@ const meta = {
|
||||
muted: false,
|
||||
},
|
||||
displaySectionMenu: true,
|
||||
canBeReordered: true,
|
||||
onClick: fn(),
|
||||
onFocus: fn(),
|
||||
editSection: fn(),
|
||||
@@ -82,9 +85,17 @@ const meta = {
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div role="treegrid" style={{ width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<div role="treegrid" style={{ width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
|
||||
+2
-2
@@ -33,14 +33,14 @@ describe("<RoomListSectionHeaderView /> stories", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Default />);
|
||||
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
|
||||
const button = screen.getByRole("button", { name: HEADER_NAME });
|
||||
await user.click(button);
|
||||
expect(Default.args.onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("focuses the button when isFocused is true", () => {
|
||||
render(<Default isFocused={true} />);
|
||||
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
|
||||
const button = screen.getByRole("button", { name: HEADER_NAME });
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
|
||||
+103
-132
@@ -5,24 +5,20 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { memo, type JSX, type FocusEvent, useEffect, useRef, useState } from "react";
|
||||
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
|
||||
import React, { memo, type JSX, type FocusEvent, useEffect, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useDroppable } from "@dnd-kit/react";
|
||||
import { useDraggable, useDragOperation, useDroppable } from "@dnd-kit/react";
|
||||
import { useMergeRefs } from "react-merge-refs";
|
||||
import { Feedback } from "@dnd-kit/dom";
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
|
||||
|
||||
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
|
||||
import styles from "./RoomListSectionHeaderView.module.css";
|
||||
import { Flex } from "../../../core/utils/Flex";
|
||||
import { useI18n } from "../../../core/i18n/i18nContext";
|
||||
import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList";
|
||||
import { _t } from "../../../core/i18n/i18n";
|
||||
import {
|
||||
NotificationDecoration,
|
||||
type NotificationDecorationData,
|
||||
} from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
|
||||
import { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
|
||||
import { isSectionDragData, type RoomListDragData, type SectionDragData } from "../dragAndDrop";
|
||||
import { type NotificationDecorationData } from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
|
||||
|
||||
/**
|
||||
* The observable state snapshot for a room list section header.
|
||||
@@ -40,6 +36,8 @@ export interface RoomListSectionHeaderViewSnapshot {
|
||||
notification?: NotificationDecorationData;
|
||||
/** Wether to display the section menu */
|
||||
displaySectionMenu: boolean;
|
||||
/** Whether the section can be reordered via drag-and-drop */
|
||||
canBeReordered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,14 +106,61 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
roomCountInSection,
|
||||
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { id, title, isExpanded, isUnread, notification, displaySectionMenu } = useViewModel(vm);
|
||||
const { id, title, isExpanded, isUnread, canBeReordered } = useViewModel(vm);
|
||||
const isLastSection = sectionIndex === sectionCount - 1;
|
||||
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable({
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
isDragSource,
|
||||
} = useDraggable<SectionDragData>({
|
||||
id,
|
||||
data: { type: "section", index: sectionIndex },
|
||||
plugins: [Feedback.configure({ feedback: "clone" })],
|
||||
modifiers: [RestrictToVerticalAxis],
|
||||
disabled: !canBeReordered,
|
||||
});
|
||||
|
||||
const { source } = useDragOperation<RoomListDragData>();
|
||||
const draggedData = source?.data;
|
||||
const isDraggingSectionSource = isSectionDragData(draggedData);
|
||||
|
||||
// Keep the droppable enabled so rooms can still be dropped on default sections
|
||||
// (Favourite / Low Priority). Only disable it for section drags on non-reorderable
|
||||
// headers so they can't be used as reorder targets.
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable<SectionDragData>({
|
||||
id,
|
||||
data: { type: "section", index: sectionIndex },
|
||||
disabled: isDragSource || (isDraggingSectionSource && !canBeReordered),
|
||||
});
|
||||
|
||||
const isDraggingRoom = isDropTarget && draggedData?.type === "room";
|
||||
const isDraggingSection = isDropTarget && isDraggingSectionSource;
|
||||
|
||||
const sourceSectionIndex = isSectionDragData(draggedData) ? draggedData.index : -1;
|
||||
const isSourceAbove = isDraggingSection && sourceSectionIndex > sectionIndex;
|
||||
const hasBottomBorder = isDraggingSection && !isSourceAbove;
|
||||
const hasTopBorder = isDraggingSection && isSourceAbove;
|
||||
|
||||
// Keep the last expanded state we rendered while NOT dragging.
|
||||
const lastExpandedRef = useRef(isExpanded);
|
||||
if (!isDragSource) {
|
||||
lastExpandedRef.current = isExpanded;
|
||||
}
|
||||
// While this header is the drag source, freeze aria-expanded at its pre-drag value. Section
|
||||
// drag start collapses every section, flipping this focused header's aria-expanded true→false;
|
||||
// announcing that state change is a second, redundant screen-reader announcement on top of the
|
||||
// dnd "Dragging X" live-region announcement. The collapse is still reflected visually.
|
||||
const ariaExpanded = isDragSource ? lastExpandedRef.current : isExpanded;
|
||||
|
||||
const internalRef = useRef<HTMLButtonElement>(null);
|
||||
const mergedRef = useMergeRefs<HTMLButtonElement>([droppableRef, internalRef]);
|
||||
// Only wire up draggable refs when the section can be dragged. Otherwise dndkit will put incorrect and misleading a11y attributes
|
||||
// on the default section (aka aria-disabled=true and aria-draggable=false)
|
||||
const buttonRef = useMergeRefs([
|
||||
...(canBeReordered ? [draggableRef, handleRef] : []),
|
||||
droppableRef,
|
||||
internalRef,
|
||||
]) as React.Ref<HTMLButtonElement>;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
@@ -125,128 +170,54 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-expanded={isExpanded}
|
||||
aria-expanded={ariaExpanded}
|
||||
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
|
||||
>
|
||||
<button
|
||||
ref={mergedRef}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={classNames(styles.header, {
|
||||
[styles.firstHeader]: sectionIndex === 0,
|
||||
// If the section is collapsed and it's the last one
|
||||
[styles.lastHeader]: !isExpanded && isLastSection,
|
||||
[styles.unread]: isUnread,
|
||||
})}
|
||||
onClick={vm.onClick}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
vm.onClick();
|
||||
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
|
||||
// Move focus to the first room in the section
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
code: "ArrowDown",
|
||||
key: "ArrowDown",
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
onFocus={(e) => onFocus(id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-label={
|
||||
isUnread
|
||||
? _t("room_list|section_header|toggle_unread", { section: title })
|
||||
: _t("room_list|section_header|toggle", { section: title })
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dropTarget]: isDropTarget,
|
||||
<div role="gridcell" aria-expanded={ariaExpanded}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={classNames(styles.header, {
|
||||
[styles.firstHeader]: sectionIndex === 0,
|
||||
// If the section is collapsed and it's the last one
|
||||
[styles.lastHeader]: !isExpanded && isLastSection,
|
||||
[styles.unread]: isUnread,
|
||||
[styles.dragSource]: isDragSource,
|
||||
[styles.dropTarget]: isDraggingRoom,
|
||||
[styles.dropTargetBottom]: hasBottomBorder,
|
||||
[styles.dropTargetTop]: hasTopBorder,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="var(--cpd-space-2x)"
|
||||
onClick={() => !isDragSource && vm.onClick()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
vm.onClick();
|
||||
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
|
||||
// Move focus to the first room in the section
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
code: "ArrowDown",
|
||||
key: "ArrowDown",
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-expanded={ariaExpanded}
|
||||
onFocus={(e) => onFocus(id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-label={
|
||||
isUnread
|
||||
? _t("room_list|section_header|toggle_unread", { section: title })
|
||||
: _t("room_list|section_header|toggle", { section: title })
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="var(--cpd-space-0-5x)">
|
||||
<ChevronRightIcon
|
||||
className={styles.chevron}
|
||||
width="24px"
|
||||
height="24px"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
/>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</Flex>
|
||||
{!isExpanded && notification && (
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...notification} />
|
||||
</div>
|
||||
)}
|
||||
{displaySectionMenu && <MenuComponent vm={vm} />}
|
||||
</Flex>
|
||||
</button>
|
||||
<RoomListSectionHeaderContent vm={vm} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface MenuComponentProps {
|
||||
vm: RoomListSectionHeaderViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Menu component for the section header.
|
||||
*/
|
||||
|
||||
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={_t("room_list|section_header|more_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton
|
||||
className={styles.menu}
|
||||
tooltip={_t("room_list|section_header|more_options")}
|
||||
aria-label={_t("room_list|section_header|more_options")}
|
||||
size="24px"
|
||||
style={{ padding: "2px" }}
|
||||
color="var(--cpd-color-icon-primary)"
|
||||
>
|
||||
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
hideChevron={true}
|
||||
Icon={EditIcon}
|
||||
label={_t("room_list|section_header|edit_section")}
|
||||
onSelect={() => vm.editSection()}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
<MenuItem
|
||||
hideChevron={true}
|
||||
Icon={DeleteIcon}
|
||||
label={_t("room_list|section_header|remove_section")}
|
||||
onSelect={() => vm.removeSection()}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
+57
-53
@@ -14,75 +14,79 @@ exports[`<RoomListSectionHeaderView /> stories > renders Default story 1`] = `
|
||||
aria-setsize="5"
|
||||
role="row"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
role="gridcell"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
<button
|
||||
aria-expanded="true"
|
||||
aria-label="Toggle Favourites section"
|
||||
class="RoomListSectionHeaderView-module_header"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="More options"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
|
||||
color="var(--cpd-color-icon-primary)"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-react-use-id-2"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px; padding: 2px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
class="Flex-module_flex RoomListSectionHeaderView-module_container"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
class="Flex-module_flex"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-primary)"
|
||||
height="1em"
|
||||
class="RoomListSectionHeaderView-module_chevron"
|
||||
fill="var(--cpd-color-icon-secondary)"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
width="24px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="RoomListSectionHeaderView-module_title"
|
||||
>
|
||||
Favourites
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="More options"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
|
||||
color="var(--cpd-color-icon-primary)"
|
||||
data-kind="primary"
|
||||
data-state="closed"
|
||||
id="radix-react-use-id-2"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px; padding: 2px;"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-primary)"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
export { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
||||
export { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
|
||||
export type { RoomListSectionHeaderContentProps } from "./RoomListSectionHeaderContent";
|
||||
export type {
|
||||
RoomListSectionHeaderViewModel,
|
||||
RoomListSectionHeaderViewSnapshot,
|
||||
|
||||
+9
@@ -37,6 +37,9 @@ const RoomListWrapperImpl = ({
|
||||
closeToast,
|
||||
renderAvatar: renderAvatarProp,
|
||||
changeRoomSection,
|
||||
changeSectionOrder,
|
||||
onSectionDragStart,
|
||||
onSectionDragEnd,
|
||||
...rest
|
||||
}: RoomListStoryProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
@@ -48,6 +51,9 @@ const RoomListWrapperImpl = ({
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
changeSectionOrder,
|
||||
onSectionDragStart,
|
||||
onSectionDragEnd,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -88,6 +94,9 @@ const meta = {
|
||||
isFlatList: true,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
changeSectionOrder: fn(),
|
||||
onSectionDragStart: fn(),
|
||||
onSectionDragEnd: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
+85
-7
@@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./VirtualizedRoomListView.stories";
|
||||
import { KEYBOARD_DRAG_OFFSET } from "./VirtualizedRoomListView";
|
||||
|
||||
const { Default, Sections } = composeStories(stories);
|
||||
|
||||
@@ -69,15 +70,19 @@ describe("<VirtualizedRoomListView />", () => {
|
||||
describe("drag and drop", () => {
|
||||
beforeEach(() => {
|
||||
// Storybook fn() spies are shared across tests; vi.clearAllMocks() may not
|
||||
// reach them, so explicitly reset call history for the spy under test.
|
||||
// reach them, so explicitly reset call history for the spies under test.
|
||||
(Sections.args.changeRoomSection as any).mockClear?.();
|
||||
(Sections.args.changeSectionOrder as any).mockClear?.();
|
||||
(Sections.args.onSectionDragStart as any).mockClear?.();
|
||||
(Sections.args.onSectionDragEnd as any).mockClear?.();
|
||||
});
|
||||
|
||||
it("should call changeRoomSection when drag ends successfully", async () => {
|
||||
// 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.
|
||||
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
|
||||
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~150px down from "General"
|
||||
// (room 0) so the drag position enters the target section header's droppable area;
|
||||
// derive the keypress count from the offset so this stays correct if the offset changes.
|
||||
const presses = Math.round(150 / KEYBOARD_DRAG_OFFSET);
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
@@ -86,8 +91,8 @@ describe("<VirtualizedRoomListView />", () => {
|
||||
|
||||
await user.keyboard(" "); // start drag
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await user.keyboard("{ArrowDown}"); // move down 10px per press
|
||||
for (let i = 0; i < presses; i++) {
|
||||
await user.keyboard("{ArrowDown}");
|
||||
}
|
||||
|
||||
await user.keyboard(" "); // drop onto current target
|
||||
@@ -96,6 +101,79 @@ describe("<VirtualizedRoomListView />", () => {
|
||||
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reflect aria-pressed onto draggable room items or section headers", async () => {
|
||||
// dnd-kit's built-in Accessibility plugin reflects aria-pressed onto the draggable
|
||||
// <button>, which VoiceOver reads as "selected" when a keyboard drag starts. We drop
|
||||
// that plugin, so the attribute must never appear (before or during a drag).
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
const roomButton = await screen.findByRole("button", { name: "Open room General" });
|
||||
const sectionHeader = await screen.findByLabelText("Toggle Favourites section");
|
||||
expect(roomButton).not.toHaveAttribute("aria-pressed");
|
||||
expect(sectionHeader).not.toHaveAttribute("aria-pressed");
|
||||
|
||||
roomButton.focus();
|
||||
await user.keyboard(" "); // start drag
|
||||
expect(roomButton).not.toHaveAttribute("aria-pressed");
|
||||
await user.keyboard("{Escape}"); // cancel drag
|
||||
});
|
||||
|
||||
it("announces drag progress in a live region", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveTextContent("");
|
||||
|
||||
const roomButton = await screen.findByRole("button", { name: "Open room General" });
|
||||
roomButton.focus();
|
||||
|
||||
await user.keyboard(" "); // start drag
|
||||
await waitFor(() => expect(status).toHaveTextContent("Dragging General"));
|
||||
|
||||
await user.keyboard("{Escape}"); // cancel
|
||||
});
|
||||
|
||||
it("exposes keyboard drag instructions referenced by draggable items", async () => {
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
// The plugin creates a hidden instructions element and wires draggables to it.
|
||||
const instructions = screen.getByText(
|
||||
"Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
|
||||
);
|
||||
const roomButton = await screen.findByRole("button", { name: "Open room General" });
|
||||
await waitFor(() => expect(roomButton).toHaveAttribute("aria-describedby", instructions.id));
|
||||
});
|
||||
|
||||
it("should reorder sections via keyboard", async () => {
|
||||
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
|
||||
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~200px down from the
|
||||
// "Favourites" section header to land on the "low-priority" section header — a valid
|
||||
// section reorder; derive the keypress count from the offset so this stays correct
|
||||
// if the offset changes.
|
||||
const presses = Math.round(200 / KEYBOARD_DRAG_OFFSET);
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
const favouritesHeader = await screen.findByLabelText("Toggle Favourites section");
|
||||
favouritesHeader.focus();
|
||||
|
||||
await user.keyboard(" "); // start drag
|
||||
|
||||
for (let i = 0; i < presses; i++) {
|
||||
await user.keyboard("{ArrowDown}");
|
||||
}
|
||||
|
||||
await user.keyboard(" "); // drop
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Sections.args.changeSectionOrder).toHaveBeenCalledWith("favourites", "low-priority");
|
||||
});
|
||||
expect(Sections.args.onSectionDragStart).toHaveBeenCalled();
|
||||
expect(Sections.args.onSectionDragEnd).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollToSectionTag", () => {
|
||||
|
||||
+43
-10
@@ -13,7 +13,6 @@ import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dn
|
||||
|
||||
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
|
||||
import { useViewModel } from "../../core/viewmodel";
|
||||
import { _t } from "../../core/i18n/i18n";
|
||||
import {
|
||||
FlatVirtualizedList,
|
||||
getContainerAccessibleProps,
|
||||
@@ -22,9 +21,13 @@ import {
|
||||
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
|
||||
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
|
||||
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
||||
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
|
||||
import { RoomListItemWrapper } from "./RoomListItemWrapper";
|
||||
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
|
||||
import { useRoomListAccessibilityPlugin } from "./RoomListAccessibilityPlugin";
|
||||
import styles from "./VirtualizedRoomListView.module.css";
|
||||
import { useI18n } from "../../core/i18n/i18nContext";
|
||||
|
||||
/**
|
||||
* Filter key type - opaque string type for filter identifiers
|
||||
@@ -69,6 +72,11 @@ export interface VirtualizedRoomListViewProps {
|
||||
/** Height of a single room list item in pixels (44px item + 8px padding bottom) */
|
||||
const ROOM_LIST_ITEM_HEIGHT = 52;
|
||||
|
||||
/**
|
||||
* Number of pixels the keyboard sensor moves the dragged element per arrow keypress.
|
||||
*/
|
||||
export const KEYBOARD_DRAG_OFFSET = 17;
|
||||
|
||||
/**
|
||||
* Type for context used in ListView
|
||||
*/
|
||||
@@ -112,6 +120,7 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
|
||||
* ```
|
||||
*/
|
||||
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const snapshot = useViewModel(vm);
|
||||
const { roomListState, sections, isFlatList } = snapshot;
|
||||
const activeRoomIndex = roomListState.activeRoomIndex;
|
||||
@@ -147,6 +156,10 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
[vm],
|
||||
);
|
||||
|
||||
// Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,
|
||||
// replacing dnd-kit's built-in Accessibility plugin.
|
||||
const a11yPlugins = useRoomListAccessibilityPlugin(vm);
|
||||
|
||||
/**
|
||||
* Get the item component for a specific index
|
||||
* Gets the room's view model and passes it to RoomListItemView
|
||||
@@ -388,13 +401,25 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
<DragDropProvider<RoomListDragData>
|
||||
onDragStart={(event) => {
|
||||
const { source } = event.operation;
|
||||
// Changing the state of sections (collapsed/expanded) while dragging a section header causes a double readback for the a11y announcement.
|
||||
if (isSectionDragData(source?.data)) {
|
||||
vm.onSectionDragStart();
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (event.canceled) return;
|
||||
const { target, source } = event.operation;
|
||||
if (!source || !target) return;
|
||||
|
||||
vm.changeRoomSection(source.id as string, target.id as string);
|
||||
const { source, target } = event.operation;
|
||||
if (isSectionDragData(source?.data)) {
|
||||
vm.onSectionDragEnd();
|
||||
}
|
||||
if (event.canceled || !source || !target) return;
|
||||
if (isSectionDragData(source.data)) {
|
||||
vm.changeSectionOrder(String(source.id), String(target.id));
|
||||
} else {
|
||||
vm.changeRoomSection(String(source.id), String(target.id));
|
||||
}
|
||||
}}
|
||||
sensors={[
|
||||
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
|
||||
@@ -404,6 +429,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
}),
|
||||
// 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({
|
||||
// The default 10px-per-keypress offset makes keyboard dragging feel sluggish.
|
||||
offset: KEYBOARD_DRAG_OFFSET,
|
||||
keyboardCodes: {
|
||||
start: ["Space"],
|
||||
cancel: ["Escape"],
|
||||
@@ -415,6 +442,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
},
|
||||
}),
|
||||
]}
|
||||
plugins={a11yPlugins}
|
||||
>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
|
||||
@@ -439,7 +467,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
|
||||
*/
|
||||
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
|
||||
const { source } = useDragOperation();
|
||||
const { source } = useDragOperation<RoomListDragData>();
|
||||
|
||||
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
|
||||
}
|
||||
@@ -455,10 +483,15 @@ interface DragOverlayContentProps {
|
||||
* 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();
|
||||
const { source } = useDragOperation<RoomListDragData>();
|
||||
if (!source) return null;
|
||||
|
||||
const itemVm = vm.getRoomItemViewModel(source.id as string);
|
||||
if (isSectionDragData(source.data)) {
|
||||
const sectionHeaderVM = vm.getSectionHeaderViewModel(String(source.id));
|
||||
return <RoomListSectionHeaderDragOverlayView vm={sectionHeaderVM} />;
|
||||
}
|
||||
|
||||
const itemVm = vm.getRoomItemViewModel(String(source.id));
|
||||
if (!itemVm) return null;
|
||||
|
||||
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Drag payload for a section header. */
|
||||
export type SectionDragData = { type: "section"; index: number };
|
||||
/** Drag payload for a room item. */
|
||||
export type RoomDragData = { type: "room" };
|
||||
/** Discriminated union of all drag payloads in the room list. */
|
||||
export type RoomListDragData = SectionDragData | RoomDragData;
|
||||
|
||||
/**
|
||||
* Type guard: true when the drag source is a section header. Narrows to {@link SectionDragData}.
|
||||
*/
|
||||
export function isSectionDragData(data: RoomListDragData | undefined): data is SectionDragData {
|
||||
return data?.type === "section";
|
||||
}
|
||||
@@ -151,6 +151,9 @@ export const createGetSectionHeaderViewModel = (
|
||||
id: sectionId,
|
||||
title: sectionId[0].toUpperCase() + sectionId.slice(1),
|
||||
isExpanded: true,
|
||||
isUnread: false,
|
||||
displaySectionMenu: false,
|
||||
canBeReordered: true,
|
||||
};
|
||||
const vm = new MockViewModel(snapshot) as unknown as RoomListSectionHeaderViewModel;
|
||||
Object.assign(vm, {
|
||||
|
||||
@@ -117,6 +117,7 @@ export default defineConfig({
|
||||
"vite-plugin-node-polyfills/shims/process",
|
||||
"@vector-im/compound-design-tokens/assets/web/icons",
|
||||
"storybook/preview-api",
|
||||
"@dnd-kit/abstract",
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user