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 10:16:34 +00:00
committed by GitHub
parent 1dd5748d6f
commit c363d2eb82
22 changed files with 1090 additions and 160 deletions
@@ -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>