Refactor and redesign user menu (#32812)

* Initial quick settings menu

* Total refactor

* Quick design fixes.

* Refactor to use a view model.

* Remove unused strings

* Apply label

* Refactor naming

* Fixup most tests

* Remove specific theming for old user menu

* prettier

* Lots of cleanup

* Allow overriding the menu classes

* update snap

* Oops translations

* tidy

* Cleanup guest flows.

* Copyrights

* Remove unused classname

* Match guest view to designs

* Add guest screenshots

* Update guests

* snapshot

* Cleanup

* fix import

* Update tests

* More sceenshot fixes

* update collapsed

* move statements to prevent flake

* update snap

* Kick it along

* Click the room list

* Fiddle with the room video list.

* More screenshot adjustments

* fix imports

* fix another import

* Update snaps

* update snaps

* Fix snap flakes

* Refactor to move actions to view component, and callbacks to Actions

* Cleanup

* Cleanup

* Cleanup

* invert auth

* More bits

* fix

* Change md buttons to sm

* Try to assemble the snapshot component of the house of cards

* Consistent newlines between tests

* Update snapshot

Not sure why this was like this, this seems consistet for a logged in user

* Update snapshot

again these seem sensible for a guest

* Remove test

I don't really understand why the thing it asserts matters, so I'm removing
it for now.

* Update snapshot

* screenshot

* Don't show profile picture for guests

I'm not really sure what it meant for this interface to have a
property with a default value, so I've removed it and added the
property to the view model.

* Show avatar in story

* update snapshots for showAvatar

* Update screenshots

& hopefully make hover consistent in one

* Use outline home icon

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
This commit is contained in:
Will Hunt
2026-05-06 08:34:36 +00:00
committed by GitHub
co-authored by David Baker
parent bbd2d81a08
commit d4f419d1b5
58 changed files with 1231 additions and 874 deletions
@@ -48,6 +48,15 @@
"open_dial_pad": "Open dial pad",
"separator_label": "Click or drag to expand"
},
"menus": {
"user_menu": {
"create_an_account": "Create an account",
"got_an_account": "Got an account?",
"manage_account": "Manage account",
"sign_in": "Sign in",
"title": "User menu"
}
},
"notifications": {
"all_messages": "All messages",
"default_settings": "Match default settings",
@@ -238,6 +247,13 @@
"view_image": "View image"
}
},
"user_menu": {
"link_new_device": "Link new device",
"open_feedback": "Feedback",
"open_home": "Home",
"open_security": "Security & Privacy",
"open_settings": "All settings"
},
"widget": {
"context_menu": {
"move_left": "Move left",
+2
View File
@@ -1,4 +1,5 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -13,6 +14,7 @@ export * from "./core/AvatarWithDetails";
export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./crypto/SasEmoji";
export * from "./menus/UserMenu";
export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/EventPresentation";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
@@ -0,0 +1,68 @@
/*
* 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.
*/
.container {
/* As per design, seperator should span whole width. */
div[role="separator"] {
margin-inline: 0;
}
max-width: 300px;
/* Override menu defaults */
padding-block: 0 !important;
section.profile {
margin: var(--cpd-space-8x) var(--cpd-space-6x);
margin-bottom: var(--cpd-space-3x);
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
> * {
margin: auto;
}
.displayname {
text-wrap: balance;
text-align: center;
}
.createAccount {
margin-top: var(--cpd-space-1x);
margin-bottom: var(--cpd-space-2x);
}
}
section.actions {
margin: var(--cpd-space-2x) 0;
margin-bottom: var(--cpd-space-3x);
padding-top: 0;
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
font: var(--cpd-font-body-md-medium);
> button[data-kind="primary"] > svg {
color: var(--cpd-color-icon-secondary);
}
}
}
.triggerButton {
border: none;
background: none;
display: flex;
color: var(--cpd-color-body-primary);
gap: var(--cpd-space-2x);
> span {
height: fit-content;
margin-top: auto;
margin-bottom: auto;
max-width: 200px;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
margin-top: var(--cpd-space-3x);
margin-bottom: var(--cpd-space-4x);
margin-left: var(--cpd-space-3x);
}
@@ -0,0 +1,178 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type Meta, type StoryObj } from "@storybook/react-vite";
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { UserMenuView, type UserMenuViewSnapshot, type UserMenuViewActions } from "./UserMenu";
import avatarUrl from "../../../static/element.png";
import { useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
const vm = useMockedViewModel<UserMenuViewSnapshot, UserMenuViewActions>(snapshot, {
setOpen: fn(),
createAccount: fn(),
signIn: fn(),
linkNewDevice: fn(),
openFeedback: fn(),
openHomePage: fn(),
openSecurity: fn(),
openSettings: fn(),
});
return <UserMenuView vm={vm} />;
};
const UserMenuWrapper = withViewDocs(UserMenuWrapperImpl, UserMenuView);
const meta = {
title: "Menus/UserMenu",
component: UserMenuWrapper,
tags: ["autodocs"],
args: {
open: false,
avatarUrl,
displayName: "Sally Sanderson",
userId: "@person-name:homeserver.com",
manageAccountHref: "#",
expanded: true,
actions: {
linkNewDevice: true,
openSecurity: true,
openFeedback: true,
openSettings: true,
},
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11583-3479&t=DwFpi7Zlq9uJr1SQ-0",
},
},
} satisfies Meta<typeof UserMenuWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const LongerName: Story = {
args: {
displayName: "Sally Sanderson with a longer name",
},
};
export const Open: Story = {
args: {
open: true,
displayName: "Sally Sanderson with a longer name",
userId: "@person-name:homeserver.com",
expanded: true,
showAvatar: true,
},
parameters: {
a11y: {
/*
* Axe's context parameter
* See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter
* to learn more.
*/
config: {
rules: [
{
// Menu contains a header which is invalid
id: "aria-required-children",
enabled: false,
},
{
// Menu pops open by default
id: "aria-hidden-focus",
enabled: false,
},
],
},
},
},
// Only used for playwright tests for the menu.
// Steals focus if actually opened on the storybook page
tags: ["!dev", "!autodocs"],
};
export const Condensed: Story = {
args: {
displayName: "Sally Sanderson with a longer name",
expanded: false,
},
};
export const Guest: Story = {
args: {
displayName: "Guest",
userId: "@guest:attendees.example.org",
manageAccountHref: undefined,
showAvatar: false,
actions: {
createAccount: true,
signIn: true,
openHomePage: true,
openFeedback: true,
openSettings: true,
},
},
};
export const GuestOpen: Story = {
args: {
...Guest.args,
open: true,
},
parameters: {
a11y: {
/*
* Axe's context parameter
* See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#context-parameter
* to learn more.
*/
config: {
rules: [
{
// Menu contains a header which is invalid
id: "aria-required-children",
enabled: false,
},
{
// Menu pops open by default
id: "aria-hidden-focus",
enabled: false,
},
],
},
},
},
// Only used for playwright tests for the menu.
// Steals focus if actually opened on the storybook page
tags: ["!dev", "!autodocs"],
};
export const AllOptions: Story = {
args: {
displayName: "Alice",
userId: "@alice:example.org",
manageAccountHref: "#",
showAvatar: true,
actions: {
createAccount: true,
signIn: true,
linkNewDevice: true,
openSecurity: true,
openHomePage: true,
openFeedback: true,
openSettings: true,
} satisfies Record<keyof UserMenuViewSnapshot["actions"], true>,
},
};
@@ -0,0 +1,41 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { render } from "@test-utils";
import React from "react";
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./UserMenu.stories.tsx";
const { Default, LongerName, Condensed, Guest } = composeStories(stories);
describe("UserMenu", () => {
it("renders a button", async () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a button with a longer name", async () => {
const { container } = render(<LongerName />);
expect(container).toMatchSnapshot();
});
it("renders condensed view", async () => {
const { container } = render(<Condensed />);
expect(container).toMatchSnapshot();
});
it("renders a menu", async () => {
const { baseElement, getByRole } = render(<Default />);
await userEvent.click(getByRole("button"));
expect(baseElement).toMatchSnapshot();
});
it("renders a guest menu", async () => {
const { baseElement, getByRole } = render(<Guest />);
await userEvent.click(getByRole("button"));
expect(baseElement).toMatchSnapshot();
});
});
@@ -0,0 +1,187 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
import {
ChatProblemIcon,
DevicesIcon,
HomeIcon,
LockIcon,
PopOutIcon,
SettingsIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import styles from "./UserMenu.module.css";
import { useViewModel, type ViewModel } from "../../core/viewmodel";
import { useI18n } from "../../core/i18n/i18nContext";
export interface UserMenuViewSnapshot {
/**
* Is the menu open or closed.
*/
open: boolean;
/**
* Is the menu toggle expanded (avatar + displayname) or collapsed (avatar).
*/
expanded: boolean;
/**
* Avatar URL for the user, if one is set.
*/
avatarUrl?: string;
/**
* Should the avatar be visible.
*/
showAvatar?: boolean;
/**
* Display name for the user.
*/
displayName: string;
/**
* Matrix user ID for the user.
*/
userId: string;
/**
* Account management URL if the user is using OIDC.
*/
manageAccountHref?: string;
/**
* A set of actions that the user can perform from the menu.
*/
actions: Partial<{
createAccount: boolean;
signIn: boolean;
openHomePage: boolean;
linkNewDevice: boolean;
openSecurity: boolean;
openFeedback: boolean;
openSettings: boolean;
}>;
}
export declare interface UserMenuViewActions {
/**
* Called when the menu is opened or closed.
*/
setOpen: (open: boolean) => void;
/**
* Called to open the create new account view.
*/
createAccount: () => void;
/**
* Called to open the sign in view.
*/
signIn: () => void;
/**
* Called to change the view to the configured home page.
*/
openHomePage: () => void;
/**
* Called to open the link new device flow.
*/
linkNewDevice: () => void;
/**
* Called to open the security tab of the settings dialog.
*/
openSecurity: () => void;
/**
* Called to open the feedback dialog.
*/
openFeedback: () => void;
/**
* Called to open the settings dialog.
*/
openSettings: () => void;
}
export type UserMenuViewProps = {
vm: ViewModel<UserMenuViewSnapshot, UserMenuViewActions>;
/**
* Class name for the container
*/
className?: string;
};
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar } = useViewModel(vm);
const { translate: _t } = useI18n();
const trigger = (
<button className={classNames(styles.triggerButton)} aria-label={_t("menus|user_menu|title")}>
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
{expanded && (
<Text type="heading" size="sm" as="span" weight="semibold">
{displayName}
</Text>
)}
</button>
);
return (
<Menu
open={open}
showTitle={false}
title={_t("menus|user_menu|title")}
trigger={trigger}
onOpenChange={vm.setOpen}
align="start"
side="bottom"
className={classNames(styles.container, className)}
>
<section className={styles.profile}>
{showAvatar && <Avatar id={userId} name={displayName} type="round" size="88px" src={avatarUrl} />}
<Text className={styles.displayname} type="heading" size="md" weight="semibold" as="span">
{displayName}
</Text>
<Text data-testid="userId" size="md" as="span" type="body">
{userId}
</Text>
{manageAccountHref && (
<Button as="a" size="md" kind="tertiary" href={manageAccountHref} Icon={PopOutIcon}>
{_t("menus|user_menu|manage_account")}
</Button>
)}
{actions.createAccount && (
<Button
className={styles.createAccount}
size="md"
as="button"
kind="primary"
onClick={vm.createAccount}
>
{_t("menus|user_menu|create_an_account")}
</Button>
)}
{actions.signIn && (
<Text as="span" weight="medium">
{_t("menus|user_menu|got_an_account")}
<Link as="button" onClick={vm.signIn}>
{_t("menus|user_menu|sign_in")}
</Link>
</Text>
)}
</section>
<Separator />
<section className={styles.actions}>
{actions.openHomePage && (
<MenuItem Icon={HomeIcon} label={_t("user_menu|open_home")} onSelect={vm.openHomePage} />
)}
{actions.linkNewDevice && (
<MenuItem Icon={DevicesIcon} label={_t("user_menu|link_new_device")} onSelect={vm.linkNewDevice} />
)}
{actions.openSecurity && (
<MenuItem Icon={LockIcon} label={_t("user_menu|open_security")} onSelect={vm.openSecurity} />
)}
{actions.openFeedback && (
<MenuItem Icon={ChatProblemIcon} label={_t("user_menu|open_feedback")} onSelect={vm.openFeedback} />
)}
{actions.openSettings && (
<MenuItem Icon={SettingsIcon} label={_t("user_menu|open_settings")} onSelect={vm.openSettings} />
)}
</section>
</Menu>
);
}
@@ -0,0 +1,203 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserMenu > renders a button 1`] = `
<div>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="closed"
id="radix-_r_0_"
type="button"
>
<span
aria-label="@person-name:homeserver.com"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="36px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/element.png"
width="36px"
/>
</span>
<span
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Sally Sanderson
</span>
</button>
</div>
`;
exports[`UserMenu > renders a button with a longer name 1`] = `
<div>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="closed"
id="radix-_r_2_"
type="button"
>
<span
aria-label="@person-name:homeserver.com"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="36px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/element.png"
width="36px"
/>
</span>
<span
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Sally Sanderson with a longer name
</span>
</button>
</div>
`;
exports[`UserMenu > renders a guest menu 1`] = `
<body>
<div>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="closed"
id="radix-_r_8_"
type="button"
>
<span
aria-label="@guest:attendees.example.org"
class="_avatar_va14e_8"
data-color="4"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="36px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/element.png"
width="36px"
/>
</span>
<span
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Guest
</span>
</button>
</div>
</body>
`;
exports[`UserMenu > renders a menu 1`] = `
<body>
<div>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="closed"
id="radix-_r_6_"
type="button"
>
<span
aria-label="@person-name:homeserver.com"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="36px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/element.png"
width="36px"
/>
</span>
<span
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
>
Sally Sanderson
</span>
</button>
</div>
</body>
`;
exports[`UserMenu > renders condensed view 1`] = `
<div>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="UserMenu-module_triggerButton"
data-state="closed"
id="radix-_r_4_"
type="button"
>
<span
aria-label="@person-name:homeserver.com"
class="_avatar_va14e_8"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 36px;"
>
<img
alt=""
class="_image_va14e_43"
data-type="round"
height="36px"
loading="lazy"
referrerpolicy="no-referrer"
src="/static/element.png"
width="36px"
/>
</span>
</button>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
UserMenuView as UserMenu,
type UserMenuViewProps,
type UserMenuViewSnapshot as UserMenuSnapshot,
type UserMenuViewActions,
} from "./UserMenu";