Room list: edit or remove custom sections (#33283)

* feat(sc): add section menu to section header

* feat(rls): add edit and remove sections

* feat(dialog): add editing mode to CreateSectionDialog

* feat(dialog): add remove section dialog

* feat(vm): wire up vm and stores

* test: update existing snapshots

* test(e2e): add playwright tests to edit and remove a section

* chore: fix remove section i18n key

* fix: able to send empty sections

* chore: update create section editing docs

* chore: remove useless fallback

* chore: add logs when section is unknown

* feat: use different wording when removing an empty section

* fix: only animate the chevron icon in the section header

* fix: change dialog subtitle weight to medium
This commit is contained in:
Florian Duros 2026-04-28 12:16:34 +02:00 committed by GitHub
parent 1dd5748d6f
commit c363d2eb82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1090 additions and 160 deletions

View File

@ -196,6 +196,68 @@ test.describe("Room list custom sections", () => {
});
});
test.describe("Section editing", () => {
test("should edit a custom section name via the section header menu", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
// Open the section header menu
const sectionHeader = getSectionHeader(page, "Work");
await sectionHeader.hover();
await sectionHeader.getByRole("button", { name: "More options" }).click();
// Click "Edit section"
await page.getByRole("menuitem", { name: "Edit section" }).click();
// The edit dialog should appear pre-filled with the current name
const dialog = page.getByRole("dialog", { name: "Edit a section" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("textbox", { name: "Section name" })).toHaveValue("Work");
// Change the name and confirm
await dialog.getByRole("textbox", { name: "Section name" }).fill("Personal");
await dialog.getByRole("button", { name: "Edit section" }).click();
// Dialog should close
await expect(dialog).not.toBeVisible();
// Section should have the new name
await expect(getSectionHeader(page, "Personal")).toBeVisible();
await expect(getSectionHeader(page, "Work")).not.toBeVisible();
});
});
test.describe("Section removal", () => {
test("should move rooms back to Chats when their section is removed", async ({ page, app }) => {
await app.client.createRoom({ name: "my room" });
await createCustomSection(page, "Work");
await createCustomSection(page, "Personal");
const roomList = getRoomList(page);
// Move room to Work section
const roomItem = roomList.getByRole("row", { name: "Open room my room" });
await roomItem.hover();
await roomItem.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "Move to" }).hover();
await page.getByRole("menuitem", { name: "Work" }).click();
await assertRoomInSection(page, "Work", "my room");
// Remove the Work section
const sectionHeader = getSectionHeader(page, "Work");
await sectionHeader.hover();
await sectionHeader.getByRole("button", { name: "More options" }).click();
await page.getByRole("menuitem", { name: "Remove section" }).click();
const dialog = page.getByRole("dialog", { name: "Remove section?" });
await dialog.getByRole("button", { name: "Remove section" }).click();
// Section should be gone
await expect(getSectionHeader(page, "Work")).not.toBeVisible();
// Room should now be in the Chats section
await assertRoomInSection(page, "Chats", "my room");
});
});
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" });

View File

@ -150,6 +150,7 @@
@import "./views/dialogs/_ModalWidgetDialog.pcss";
@import "./views/dialogs/_PollCreateDialog.pcss";
@import "./views/dialogs/_RegistrationEmailPromptDialog.pcss";
@import "./views/dialogs/_RemoveSectionDialog.pcss";
@import "./views/dialogs/_ReportRoomDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialogBridges.pcss";

View File

@ -0,0 +1,10 @@
/*
* 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.
*/
.mx_RemoveSectionDialog {
color: var(--cpd-color-text-primary);
}

View File

@ -14,6 +14,11 @@ import DialogButtons from "../elements/DialogButtons";
import { _t } from "../../../languageHandler";
interface CreateSectionDialogProps {
/**
* The name of the section being edited if defined. Otherwise, create a new section.
*/
sectionToEdit?: string;
/**
* Callback called when the dialog is closed.
* @param shouldCreateSection Whether a section should be created or not. This will be false if the user cancels the dialog.
@ -25,36 +30,43 @@ interface CreateSectionDialogProps {
/**
* Dialog shown to the user to create a new section in the room list.
*/
export function CreateSectionDialog({ onFinished }: CreateSectionDialogProps): JSX.Element {
const [value, setValue] = useState("");
export function CreateSectionDialog({ onFinished, sectionToEdit }: CreateSectionDialogProps): JSX.Element {
const isEdition = Boolean(sectionToEdit);
const [value, setValue] = useState(sectionToEdit ?? "");
const isInvalid = Boolean(value.trim().length === 0);
return (
<BaseDialog
className="mx_CreateSectionDialog"
onFinished={() => onFinished(false, value)}
title={_t("create_section_dialog|title")}
title={isEdition ? _t("create_section_dialog|title_edition") : _t("create_section_dialog|title")}
hasCancel={true}
>
<Flex gap="var(--cpd-space-6x)" direction="column" className="mx_CreateSectionDialog_content">
<Text as="span" weight="semibold">
<Text as="span" weight="medium">
{_t("create_section_dialog|description")}
</Text>
<Form.Root
className="mx_CreateSectionDialog_form"
onSubmit={(e) => {
onFinished(true, value);
e.preventDefault();
if (!isInvalid) onFinished(true, value);
}}
>
<Form.Field name="sectionName">
<Form.Label> {_t("create_section_dialog|label")}</Form.Label>
<Form.TextControl onChange={(evt) => setValue(evt.target.value)} required={true} />
<Form.TextControl
value={value}
onChange={(evt) => setValue(evt.target.value)}
required={true}
/>
</Form.Field>
</Form.Root>
</Flex>
<DialogButtons
primaryButton={_t("create_section_dialog|create_section")}
primaryButton={
isEdition ? _t("create_section_dialog|edit_section") : _t("create_section_dialog|create_section")
}
primaryDisabled={isInvalid}
hasCancel={true}
onCancel={() => onFinished(false, "")}

View File

@ -0,0 +1,48 @@
/*
* 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 { type JSX } from "react";
import { Text } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface RemoveSectionDialogProps {
onFinished: (shouldRemoveSection: boolean) => void;
/** Whether the section is empty */
isEmpty: boolean;
}
/**
* Dialog shown to the user to remove section in the room list.
*/
export function RemoveSectionDialog({ onFinished, isEmpty }: RemoveSectionDialogProps): JSX.Element {
return (
<BaseDialog
className="mx_RemoveSectionDialog"
onFinished={() => onFinished(false)}
title={_t("remove_section_dialog|title")}
hasCancel={true}
>
<Text as="span">{_t("remove_section_dialog|confirmation")}</Text>
{!isEmpty && (
<>
<br />
<Text as="span">{_t("remove_section_dialog|description")}</Text>
</>
)}
<DialogButtons
primaryButton={_t("remove_section_dialog|remove_section")}
hasCancel={true}
onCancel={() => onFinished(false)}
onPrimaryButtonClick={() => onFinished(true)}
/>
</BaseDialog>
);
}

View File

@ -683,8 +683,10 @@
"create_section_dialog": {
"create_section": "Create section",
"description": "Sections are only for you",
"edit_section": "Edit section",
"label": "Section name",
"title": "Create a section"
"title": "Create a section",
"title_edition": "Edit a section"
},
"create_space": {
"add_details_prompt": "Add some details to help people recognise it.",
@ -1851,6 +1853,12 @@
"ongoing": "Removing…",
"reason_label": "Reason (optional)"
},
"remove_section_dialog": {
"confirmation": "Are you sure you want to remove this section?",
"description": "The chats in this section will still be available in your chats list.",
"remove_section": "Remove section",
"title": "Remove section?"
},
"report_content": {
"description": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.",
"disagree": "Disagree",

View File

@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
import { TagFilter } from "./skip-list/filters/TagFilter";
import { filterBoolean } from "../../utils/arrays";
import { createSection } from "./section";
import { createSection, deleteSection, editSection } from "./section";
/**
* These are the filters passed to the room skip list.
@ -498,6 +498,25 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
return tag;
}
/**
* Edit a section's name.
* @param tag The tag of the section to edit
*/
public async editSection(tag: string): Promise<void> {
await editSection(tag);
}
/**
* Remove a section
* Emits {@link LISTS_UPDATE_EVENT} if the section was successfully removed.
* @param tag The tag of the section to remove
* @param isEmpty Whether the section is empty
*/
public async removeSection(tag: string, isEmpty: boolean): Promise<void> {
await deleteSection(tag, isEmpty);
this.scheduleEmit();
}
/**
* Returns the ordered section tags.
*/

View File

@ -5,10 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { SettingLevel } from "../../settings/SettingLevel";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
type Tag = string;
@ -69,3 +72,52 @@ export async function createSection(): Promise<string | undefined> {
await SettingsStore.setValue("RoomList.OrderedCustomSections", null, SettingLevel.ACCOUNT, orderedSections);
return tag;
}
/**
* Edits an existing custom section by showing a dialog to the user to enter the new section name. If the user confirms, it updates the section data in the settings.
* @param tag - The tag of the section to edit.
*/
export async function editSection(tag: string): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const section = sectionData[tag];
if (!section) {
logger.info("Unknown section tag, cannot edit section", tag);
return;
}
const modal = Modal.createDialog(CreateSectionDialog, { sectionToEdit: section.name });
const [shouldEditSection, newName] = await modal.finished;
const isSameName = newName === section.name;
if (!shouldEditSection || !newName || isSameName) return;
// Save the new name
sectionData[tag].name = newName;
await SettingsStore.setValue("RoomList.CustomSectionData", null, SettingLevel.ACCOUNT, sectionData);
}
/**
* Deletes a custom section by showing a confirmation dialog to the user. If the user confirms, it removes the section data from the settings and updates the ordered list of sections.
* @param tag - The tag of the section to delete.
* @param isEmpty - Whether the section is empty (has no rooms). If the section is not empty, the confirmation dialog will show a warning message.
*/
export async function deleteSection(tag: string, isEmpty: boolean): Promise<void> {
const sectionData = SettingsStore.getValue("RoomList.CustomSectionData");
if (!sectionData[tag]) {
logger.info("Unknown section tag, cannot delete section", tag);
return;
}
const modal = Modal.createDialog(RemoveSectionDialog, { isEmpty });
const [shouldRemoveSection] = await modal.finished;
if (!shouldRemoveSection) return;
// Remove the section from the ordered list of sections
const orderedSections = SettingsStore.getValue("RoomList.OrderedCustomSections");
const newOrderedSections = orderedSections.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);
}

View File

@ -15,6 +15,9 @@ import {
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
interface RoomListSectionHeaderViewModelProps {
tag: string;
@ -42,7 +45,19 @@ export class RoomListSectionHeaderViewModel
private readonly expandedBySpace = new Map<string, boolean>();
public constructor(props: RoomListSectionHeaderViewModelProps) {
super(props, { id: props.tag, title: props.title, isExpanded: true, isUnread: false });
const isDefaultSection =
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
super(props, {
id: props.tag,
title: props.title,
isExpanded: true,
isUnread: false,
displaySectionMenu: !isDefaultSection,
});
const sectionWatherRef = SettingsStore.watchSetting("RoomList.CustomSectionData", null, () =>
this.onCustomSectionDataChange(),
);
this.disposables.track(() => SettingsStore.unwatchSetting(sectionWatherRef));
}
public onClick = (): void => {
@ -120,4 +135,25 @@ export class RoomListSectionHeaderViewModel
this.roomNotificationStates.clear();
super.dispose();
}
/**
* Handle changes to custom section data.
*/
private onCustomSectionDataChange(): void {
const customSectionData = SettingsStore.getValue("RoomList.CustomSectionData") || {};
const sectionData = customSectionData[this.props.tag];
if (sectionData) {
this.snapshot.merge({ title: sectionData.name });
}
}
public editSection = async (): Promise<void> => {
await RoomListStoreV3.instance.editSection(this.props.tag);
};
public removeSection = async (): Promise<void> => {
// There is one notification state per room in the section
const isEmpty = this.roomNotificationStates.size === 0;
await RoomListStoreV3.instance.removeSection(this.props.tag, isEmpty);
};
}

View File

@ -56,4 +56,38 @@ describe("CreateSectionDialog", () => {
await userEvent.keyboard("{Enter}");
expect(onFinished).toHaveBeenCalledWith(true, "My section");
});
describe("editing mode", () => {
function renderEditComponent(): void {
render(<CreateSectionDialog onFinished={onFinished} sectionToEdit="Existing Section" />);
}
it("pre-fills the input with the existing section name", () => {
renderEditComponent();
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Existing Section");
});
it("shows the edit section button instead of create section", () => {
renderEditComponent();
expect(screen.getByRole("button", { name: "Edit section" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create section" })).not.toBeInTheDocument();
});
it("calls onFinished with the updated name when edit section is clicked", async () => {
renderEditComponent();
const input = screen.getByRole("textbox");
await userEvent.clear(input);
await userEvent.type(input, "Updated Section");
await userEvent.click(screen.getByRole("button", { name: "Edit section" }));
expect(onFinished).toHaveBeenCalledWith(true, "Updated Section");
});
it("has the edit section button disabled when the input is empty", async () => {
renderEditComponent();
const input = screen.getByRole("textbox");
await userEvent.clear(input);
expect(screen.getByRole("button", { name: "Edit section" })).toBeDisabled();
});
});
});

View File

@ -0,0 +1,48 @@
/*
* 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 { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { RemoveSectionDialog } from "../../../../../src/components/views/dialogs/RemoveSectionDialog";
describe("RemoveSectionDialog", () => {
const onFinished: jest.Mock = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
});
it("renders the dialog when section is not empty", () => {
const { container } = render(<RemoveSectionDialog onFinished={onFinished} isEmpty={false} />);
expect(container).toMatchSnapshot();
expect(
screen.getByText("The chats in this section will still be available in your chats list."),
).toBeInTheDocument();
});
it("renders the dialog when section is empty", () => {
const { container } = render(<RemoveSectionDialog onFinished={onFinished} isEmpty={true} />);
expect(container).toMatchSnapshot();
expect(
screen.queryByText("The chats in this section will still be available in your chats list."),
).not.toBeInTheDocument();
});
it("calls onFinished with true when remove section is clicked", async () => {
render(<RemoveSectionDialog onFinished={onFinished} isEmpty={false} />);
await userEvent.click(screen.getByRole("button", { name: "Remove section" }));
expect(onFinished).toHaveBeenCalledWith(true);
});
it("calls onFinished with false when the dialog is cancelled", async () => {
render(<RemoveSectionDialog onFinished={onFinished} isEmpty={false} />);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onFinished).toHaveBeenCalledWith(false);
});
});

View File

@ -29,7 +29,7 @@ exports[`CreateSectionDialog renders the dialog 1`] = `
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55"
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60"
>
Sections are only for you
</span>
@ -52,6 +52,7 @@ exports[`CreateSectionDialog renders the dialog 1`] = `
name="sectionName"
required=""
title=""
value=""
/>
</div>
</form>

View File

@ -0,0 +1,161 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RemoveSectionDialog renders the dialog when section is empty 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_RemoveSectionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Remove section?
</h1>
</div>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Are you sure you want to remove this section?
</span>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Remove section
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`RemoveSectionDialog renders the dialog when section is not empty 1`] = `
<div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_RemoveSectionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
tabindex="-1"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Remove section?
</h1>
</div>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Are you sure you want to remove this section?
</span>
<br />
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
The chats in this section will still be available in your chats list.
</span>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-testid="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-testid="dialog-primary-button"
type="button"
>
Remove section
</button>
</span>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
/>
</svg>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View File

@ -1046,6 +1046,38 @@ describe("RoomListStoreV3", () => {
});
});
describe("editSection", () => {
it("delegates to the section module", async () => {
enableSections();
getClientAndRooms();
const editSectionSpy = jest.spyOn(sectionModule, "editSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
await store.editSection("element.io.section.test-tag");
expect(editSectionSpy).toHaveBeenCalledWith("element.io.section.test-tag");
});
});
describe("removeSection", () => {
it("delegates to the section module and emits LISTS_UPDATE_EVENT", async () => {
enableSections();
getClientAndRooms();
jest.spyOn(sectionModule, "deleteSection").mockResolvedValue(undefined);
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const listsUpdateListener = jest.fn();
store.on(LISTS_UPDATE_EVENT, listsUpdateListener);
await store.removeSection("element.io.section.test-tag", false);
expect(sectionModule.deleteSection).toHaveBeenCalledWith("element.io.section.test-tag", false);
expect(listsUpdateListener).toHaveBeenCalled();
});
});
it("updates sections when RoomList.OrderedCustomSections setting changes", async () => {
enableSections();
const { rooms } = getClientAndRooms();

View File

@ -7,75 +7,200 @@
import Modal from "../../../../src/Modal";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { createSection } from "../../../../src/stores/room-list-v3/section";
import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section";
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
describe("section", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it.each([
[false, "", undefined],
[true, "", undefined],
[true, "My Section", expect.stringMatching(/^element\.io\.section\./)],
])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldCreate, name]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toEqual(expected);
});
it("returns the new tag when section is created", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toMatch(/^element\.io\.section\./);
});
it("opens the CreateSectionDialog", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await createSection();
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog);
});
it("saves section data and ordered sections at ACCOUNT level when confirmed", async () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
return null;
describe("createSection", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(null);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await createSection();
it.each([
[false, "", undefined],
[true, "", undefined],
[true, "My Section", expect.stringMatching(/^element\.io\.section\./)],
])("returns %s when shouldCreate=%s and name='%s'", async (shouldCreate, name, expected) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldCreate, name]),
close: jest.fn(),
} as any);
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const result = await createSection();
expect(result).toEqual(expected);
});
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
it("returns the new tag when section is created", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const result = await createSection();
expect(result).toMatch(/^element\.io\.section\./);
});
it("opens the CreateSectionDialog", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await createSection();
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog);
});
it("saves section data and ordered sections at ACCOUNT level when confirmed", async () => {
const existingTag = "element.io.section.existing";
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [existingTag];
return null;
});
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "My Section"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await createSection();
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
const savedSection = Object.values(customDataCall![3] as Record<string, { tag: string; name: string }>)[0];
expect(savedSection.name).toBe("My Section");
expect(savedSection.tag).toMatch(/^element\.io\.section\./);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
const savedOrder = orderedCall![3] as string[];
expect(savedOrder[0]).toBe(existingTag);
expect(savedOrder[1]).toMatch(/^element\.io\.section\./);
});
});
describe("editSection", () => {
const tag = "element.io.section.abc";
const existingSectionData = { [tag]: { tag, name: "Old Name" } };
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(existingSectionData);
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await editSection(tag);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it("opens the CreateSectionDialog with the current section name", async () => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false, ""]),
close: jest.fn(),
} as any);
await editSection(tag);
expect(createDialogSpy).toHaveBeenCalledWith(CreateSectionDialog, { sectionToEdit: "Old Name" });
});
it.each([
[false, "New Name"],
[true, ""],
[true, "Old Name"],
])("does not save when shouldEdit=%s and name='%s'", async (shouldEdit, name) => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([shouldEdit, name]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await editSection(tag);
expect(setValueSpy).not.toHaveBeenCalled();
});
it("saves the new name when confirmed with a different name", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, "New Name"]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await editSection(tag);
expect(setValueSpy).toHaveBeenCalledWith(
"RoomList.CustomSectionData",
null,
expect.anything(),
expect.objectContaining({ [tag]: { tag, name: "New Name" } }),
);
});
});
describe("deleteSection", () => {
const tag = "element.io.section.abc";
const otherTag = "element.io.section.other";
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.CustomSectionData") return { [tag]: { tag, name: "My Section" } };
if (setting === "RoomList.OrderedCustomSections") return [otherTag, tag];
return null;
});
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
});
it("does nothing if the section does not exist", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
const createDialogSpy = jest.spyOn(Modal, "createDialog");
await deleteSection(tag, false);
expect(createDialogSpy).not.toHaveBeenCalled();
});
it.each([
[true, "empty"],
[false, "non-empty"],
])("opens the RemoveSectionDialog with isEmpty=%s for %s section", async (isEmpty) => {
const createDialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false]),
close: jest.fn(),
} as any);
await deleteSection(tag, isEmpty);
expect(createDialogSpy).toHaveBeenCalledWith(RemoveSectionDialog, { isEmpty });
});
it("does not save when user cancels", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([false]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await deleteSection(tag, false);
expect(setValueSpy).not.toHaveBeenCalled();
});
it("removes the section from ordered list and section data when confirmed", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true]),
close: jest.fn(),
} as any);
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
await deleteSection(tag, false);
const orderedCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.OrderedCustomSections");
expect(orderedCall![3]).toEqual([otherTag]);
const customDataCall = setValueSpy.mock.calls.find(([name]) => name === "RoomList.CustomSectionData");
expect(customDataCall![3]).not.toHaveProperty(tag);
});
});
});

View File

@ -12,6 +12,9 @@ import { RoomNotificationState } from "../../../src/stores/notifications/RoomNot
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { createTestClient, mkRoom } from "../../test-utils";
import SettingsStore from "../../../src/settings/SettingsStore";
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
describe("RoomListSectionHeaderViewModel", () => {
let onToggleExpanded: jest.Mock;
@ -20,6 +23,12 @@ describe("RoomListSectionHeaderViewModel", () => {
beforeEach(() => {
onToggleExpanded = jest.fn();
matrixClient = createTestClient();
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("watcher-id");
jest.spyOn(SettingsStore, "unwatchSetting").mockReturnValue(undefined);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.OrderedCustomSections") return [];
return null;
});
});
afterEach(() => {
@ -87,6 +96,121 @@ describe("RoomListSectionHeaderViewModel", () => {
expect(vm.isExpanded).toBe(false);
});
describe("displaySectionMenu", () => {
it.each([
[DefaultTagID.Favourite, false],
[DefaultTagID.LowPriority, false],
[CHATS_TAG, false],
["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().displaySectionMenu).toBe(expected);
});
});
describe("onCustomSectionDataChange", () => {
let watchCallback: () => void;
beforeEach(() => {
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((settingName, _roomId, callback) => {
if (settingName === "RoomList.CustomSectionData") watchCallback = callback as () => void;
return "watcher-id";
});
});
it("should update title when custom section data changes", () => {
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Old Title",
spaceId: "!space:server",
onToggleExpanded,
});
expect(vm.getSnapshot().title).toBe("Old Title");
jest.spyOn(SettingsStore, "getValue").mockReturnValue({ [tag]: { tag, name: "New Title" } });
watchCallback();
expect(vm.getSnapshot().title).toBe("New Title");
});
it("should not update title when section data is missing", () => {
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "My Section",
spaceId: "!space:server",
onToggleExpanded,
});
jest.spyOn(SettingsStore, "getValue").mockReturnValue({});
watchCallback();
expect(vm.getSnapshot().title).toBe("My Section");
});
});
describe("editSection", () => {
it("should delegate to RoomListStoreV3.instance.editSection", async () => {
const editSectionSpy = jest.spyOn(RoomListStoreV3.instance, "editSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
await vm.editSection();
expect(editSectionSpy).toHaveBeenCalledWith(tag);
});
});
describe("removeSection", () => {
beforeEach(() => {
const mockState = {
on: jest.fn(),
off: jest.fn(),
hasAnyNotificationOrActivity: false,
} as unknown as RoomNotificationState;
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(mockState);
});
it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=true when no rooms", async () => {
const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
await vm.removeSection();
expect(removeSectionSpy).toHaveBeenCalledWith(tag, true);
});
it("should delegate to RoomListStoreV3.instance.removeSection with isEmpty=false when rooms exist", async () => {
const removeSectionSpy = jest.spyOn(RoomListStoreV3.instance, "removeSection").mockResolvedValue(undefined);
const tag = "element.io.section.custom";
const vm = new RoomListSectionHeaderViewModel({
tag,
title: "Section",
spaceId: "!space:server",
onToggleExpanded,
});
vm.setRooms([mkRoom(matrixClient, "!room:server")]);
await vm.removeSection();
expect(removeSectionSpy).toHaveBeenCalledWith(tag, false);
});
});
describe("unread status", () => {
let room: Room;
let notificationState: RoomNotificationState;

View File

@ -147,6 +147,9 @@
"room_options": "Room Options",
"section_created": "Section created",
"section_header": {
"edit_section": "Edit section",
"more_options": "More options",
"remove_section": "Remove section",
"toggle": "Toggle %(section)s section",
"toggle_unread": "Toggle %(section)s section with unread room(s)"
},

View File

@ -8376,24 +8376,30 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
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;"
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
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>
</button>
</div>
@ -11528,24 +11534,30 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
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;"
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
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>
</button>
</div>
@ -13693,24 +13705,30 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
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;"
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
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>
</button>
</div>
@ -14020,24 +14038,30 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
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;"
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
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>
</button>
</div>

View File

@ -19,7 +19,8 @@
background-color: var(--cpd-color-bg-canvas-default);
&:hover,
&:focus-visible {
&:focus-visible,
&:has(button[data-state="open"]) {
color: var(--cpd-color-text-primary);
svg {
@ -29,20 +30,24 @@
.container {
background-color: var(--cpd-color-bg-action-tertiary-hovered);
}
.menu {
display: initial;
}
}
svg {
.chevron {
transition: transform 0.05s linear;
}
@media (prefers-reduced-motion: reduce) {
svg {
.chevron {
transition: none;
}
}
&[aria-expanded="true"] {
svg {
.chevron {
transform: rotate(90deg);
}
}
@ -58,6 +63,10 @@
padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x);
border-radius: 8px;
div {
min-width: 0;
}
svg {
flex-shrink: 0;
}
@ -77,3 +86,7 @@
.lastHeader {
padding-bottom: 0;
}
.menu {
display: none;
}

View File

@ -25,6 +25,8 @@ type RoomListSectionHeaderProps = RoomListSectionHeaderViewSnapshot &
const RoomListSectionHeaderViewWrapperImpl = ({
onClick,
onFocus,
editSection,
removeSection,
isFocused,
sectionIndex,
sectionCount,
@ -32,7 +34,7 @@ const RoomListSectionHeaderViewWrapperImpl = ({
roomCountInSection,
...rest
}: RoomListSectionHeaderProps): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick });
const vm = useMockedViewModel(rest, { onClick, editSection, removeSection });
return (
<RoomListSectionHeaderView
vm={vm}
@ -57,8 +59,11 @@ const meta = {
isExpanded: true,
isFocused: false,
isUnread: false,
displaySectionMenu: true,
onClick: fn(),
onFocus: fn(),
editSection: fn(),
removeSection: fn(),
sectionIndex: 1,
sectionCount: 3,
roomCountInSection: 5,

View File

@ -5,15 +5,18 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { memo, type JSX, type FocusEvent, type MouseEventHandler } from "react";
import React, { memo, type JSX, type FocusEvent, type MouseEventHandler, 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, 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";
/**
* The observable state snapshot for a room list section header.
@ -27,6 +30,8 @@ export interface RoomListSectionHeaderViewSnapshot {
isExpanded: boolean;
/** Whether the section is unread (has any unread rooms) */
isUnread: boolean;
/** Wether to display the section menu */
displaySectionMenu: boolean;
}
/**
@ -35,6 +40,10 @@ export interface RoomListSectionHeaderViewSnapshot {
export interface RoomListSectionHeaderActions {
/** Handler invoked when the section header is clicked (toggles expand/collapse). */
onClick: MouseEventHandler<HTMLButtonElement>;
/** Handler invoked when the edit section button is clicked */
editSection: () => void;
/** Handler invoked when the remove section button is clicked */
removeSection: () => void;
}
/**
@ -91,7 +100,7 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
roomCountInSection,
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { id, title, isExpanded, isUnread } = useViewModel(vm);
const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
return (
@ -118,11 +127,75 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex className={styles.container} align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon width="24px" height="24px" fill="var(--cpd-color-icon-secondary)" />
<span className={styles.title}>{title}</span>
<Flex className={styles.container} 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>
{displaySectionMenu && <MenuComponent vm={vm} />}
</Flex>
</button>
</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>
);
}

View File

@ -24,24 +24,63 @@ exports[`<RoomListSectionHeaderView /> stories > renders Default story 1`] = `
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
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;"
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
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"
<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="_r_2_"
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
color="var(--cpd-color-icon-primary)"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 24px; padding: 2px;"
tabindex="0"
type="button"
>
Favourites
</span>
<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>