mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-04 19:56:45 +02:00
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:
parent
1dd5748d6f
commit
c363d2eb82
@ -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" });
|
||||
|
||||
@ -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";
|
||||
|
||||
10
apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss
Normal file
10
apps/web/res/css/views/dialogs/_RemoveSectionDialog.pcss
Normal 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);
|
||||
}
|
||||
@ -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, "")}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user