User status in user menu (#33797)
* Add user status on user profile icon Currently unstyled & no tests * Style the user status icon * Update snapshot for avatar wrapper * More snapshot updates * add if braces * Split out user status functions to avoid circular dep which has the weird effect of just breaking jest's mocking. * type imports * Update imports * Update snapshot * Tests * baseline image * Just snapshot the component itself * User status in user menu first pass, unstyled * fix export * superstylin' * snapshots * Add story & test * Snapshots and re-use the repeated rules * Tests * Fix jest lcov projectRoot config * Change tooltip text to just 'clear' * Add comment & fix console warn * Remove all options screenshot as it's not really particularly useful as a preset story * remove stale screenshot * fix imports --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
co-authored by
Michael Telatynski
parent
44c540eca0
commit
3ab233940c
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A tuple of an emoji and string representing a user's MSC4426 status.
|
||||
* The emoji should be a single grapheme cluster.
|
||||
*/
|
||||
export interface UserStatus {
|
||||
emoji: string;
|
||||
text: string;
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
},
|
||||
"menus": {
|
||||
"user_menu": {
|
||||
"clear_status": "Clear",
|
||||
"create_an_account": "Create an account",
|
||||
"got_an_account": "Got an account?",
|
||||
"manage_account": "Manage account",
|
||||
|
||||
@@ -88,5 +88,6 @@ export * from "./core/utils/numbers";
|
||||
export * from "./core/utils/FormattingUtils";
|
||||
export * from "./core/i18n/I18nApi";
|
||||
export * from "./core/utils/linkify";
|
||||
export type * from "./core/userStatus.ts";
|
||||
// MVVM
|
||||
export * from "./core/viewmodel";
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.statusEmoji {
|
||||
.iconStatusEmoji {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -77,6 +77,33 @@
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
.statusButton {
|
||||
border-radius: 30px;
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline-start: var(--cpd-space-2x);
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
.menuStatusEmoji {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuStatusText {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
span {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
@@ -24,6 +24,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
|
||||
openHomePage: fn(),
|
||||
openSecurity: fn(),
|
||||
openSettings: fn(),
|
||||
clearStatus: fn(),
|
||||
});
|
||||
return <UserMenuView vm={vm} />;
|
||||
};
|
||||
@@ -67,6 +68,21 @@ export const LongerName: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Rules needed for any story where the menu is open, for the reasons
|
||||
// given in each rule.
|
||||
const MENU_OPEN_A11Y_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,
|
||||
},
|
||||
];
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
@@ -83,18 +99,7 @@ export const Open: Story = {
|
||||
* 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,
|
||||
},
|
||||
],
|
||||
rules: MENU_OPEN_A11Y_RULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -150,18 +155,7 @@ export const GuestOpen: Story = {
|
||||
* 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,
|
||||
},
|
||||
],
|
||||
rules: MENU_OPEN_A11Y_RULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -170,26 +164,28 @@ export const GuestOpen: Story = {
|
||||
tags: ["!dev", "!autodocs"],
|
||||
};
|
||||
|
||||
export const WithStatusEmoji: Story = {
|
||||
export const WithStatus: Story = {
|
||||
args: {
|
||||
statusEmoji: "🐹",
|
||||
userStatus: {
|
||||
emoji: "🐹",
|
||||
text: "On the wheel",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AllOptions: Story = {
|
||||
export const WithStatusOpen: 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>,
|
||||
open: true,
|
||||
userStatus: {
|
||||
emoji: "🐹",
|
||||
text: "On the wheel",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: MENU_OPEN_A11Y_RULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./UserMenu.stories.tsx";
|
||||
|
||||
const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatusEmoji } = composeStories(stories);
|
||||
const { Default, LongerName, Condensed, Guest, Open, NoAvatar, WithStatus, WithStatusOpen } = composeStories(stories);
|
||||
|
||||
describe("UserMenu", () => {
|
||||
it("renders a button", async () => {
|
||||
@@ -29,7 +29,7 @@ describe("UserMenu", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders the status emoji when set", async () => {
|
||||
const { container } = render(<WithStatusEmoji />);
|
||||
const { container } = render(<WithStatus />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a menu", async () => {
|
||||
@@ -50,4 +50,8 @@ describe("UserMenu", () => {
|
||||
const { baseElement } = render(<NoAvatar />);
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
it("renders an open menu with user status", async () => {
|
||||
const { container } = render(<WithStatusOpen />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import { Avatar, Button, IconButton, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
ChatProblemIcon,
|
||||
DevicesIcon,
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
LockIcon,
|
||||
PopOutIcon,
|
||||
SettingsIcon,
|
||||
CloseIcon,
|
||||
} 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";
|
||||
import { type UserStatus } from "../../core/userStatus";
|
||||
import { _t } from "../..";
|
||||
|
||||
export interface UserMenuViewSnapshot {
|
||||
/**
|
||||
@@ -51,9 +54,9 @@ export interface UserMenuViewSnapshot {
|
||||
*/
|
||||
manageAccountHref?: string;
|
||||
/**
|
||||
* The user status emoji to display, or undefined for no icon.
|
||||
* The user status to display, or undefined for no icon / status.
|
||||
*/
|
||||
statusEmoji?: string;
|
||||
userStatus?: UserStatus;
|
||||
/**
|
||||
* A set of actions that the user can perform from the menu.
|
||||
*/
|
||||
@@ -101,6 +104,10 @@ export declare interface UserMenuViewActions {
|
||||
* Called to open the settings dialog.
|
||||
*/
|
||||
openSettings: () => void;
|
||||
/**
|
||||
* Called when the user clicks the button to clear theirt status.
|
||||
*/
|
||||
clearStatus: () => void;
|
||||
}
|
||||
|
||||
export type UserMenuViewProps = {
|
||||
@@ -111,15 +118,40 @@ export type UserMenuViewProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function StatusButton({ status, clearStatus }: { status: UserStatus; clearStatus: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.statusButton}>
|
||||
<Text as="span" className={styles.menuStatusEmoji}>
|
||||
{status.emoji}
|
||||
</Text>
|
||||
<Text as="span" className={styles.menuStatusText}>
|
||||
{status.text}
|
||||
</Text>
|
||||
<IconButton
|
||||
onClick={clearStatus}
|
||||
aria-label={_t("menus|user_menu|clear_status")}
|
||||
tooltip={_t("menus|user_menu|clear_status")}
|
||||
size="28px"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
|
||||
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, statusEmoji } =
|
||||
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, userStatus } =
|
||||
useViewModel(vm);
|
||||
const { translate: _t } = useI18n();
|
||||
const trigger = (
|
||||
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
|
||||
<div className={styles.avatarWrapper}>
|
||||
<Avatar id={userId} name={displayName} type="round" size="36px" src={avatarUrl} />
|
||||
{statusEmoji && <span className={styles.statusEmoji}>{statusEmoji}</span>}
|
||||
{userStatus && (
|
||||
<Text as="div" className={styles.iconStatusEmoji}>
|
||||
{userStatus.emoji}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -143,6 +175,7 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element
|
||||
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
|
||||
{displayName}
|
||||
</Text>
|
||||
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
|
||||
<Text data-testid="userId" size="md" as="span" type="body">
|
||||
{userId}
|
||||
</Text>
|
||||
|
||||
@@ -845,6 +845,62 @@ exports[`UserMenu > renders an open menu 1`] = `
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders an open menu with user status 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="UserMenu-module_wrapper"
|
||||
>
|
||||
<button
|
||||
aria-controls="radix-react-use-id-1"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="menu"
|
||||
aria-label="User menu"
|
||||
class="UserMenu-module_triggerButton"
|
||||
data-state="open"
|
||||
id="radix-react-use-id-2"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="UserMenu-module_avatarWrapper"
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
|
||||
>
|
||||
🐹
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 UserMenu-module_displayName"
|
||||
>
|
||||
Sally Sanderson
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UserMenu > renders condensed view 1`] = `
|
||||
<div>
|
||||
<div
|
||||
@@ -923,11 +979,11 @@ exports[`UserMenu > renders the status emoji when set 1`] = `
|
||||
width="36px"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="UserMenu-module_statusEmoji"
|
||||
<div
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_iconStatusEmoji"
|
||||
>
|
||||
🐹
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user