UI for setting user status (#33856)
* First stage 1. Make avatar BIGGER! 2. Move status button out to its own component so we can reuse it * Working version but using context menu rather than dropdown menu which almost looks right but not quite. * Commit the set status view component * Change to use dropdown with custom trigger * Show full set status component in user menu and styling tweaks * In user menu, clicking takes you to settings * Add the view model * oxfmt because apparently mine had decided to go away * Fix type import * Pass set status viewmodel in story * Bump compound-web for customisable dropdown * Snapshot * Add explicit aria label as combobox role things don't just inherit from their inner text * snapshot again * Only show user status if feature flag enabled * Make status btton view not a button because it doesn't need to be. Also rename accordingly. * Screenshots * Disable in settings if feature flag off * Snapshot * Snapshot again * Update screenshots including room settings one which wasn't really supposed to change although the avatar now takes up the space it's in - leaving it for design to check. * Fix size & positioning of the status button * Update screenshots * Screenshots * Test for SetStatusViewModel * Update set status view on status change * Use a link component for the status button which has built in hover state * Float the status button in a 28px container vs making it actually 28px min height * Separate user menu profile into two sections With 8px gap between things in the two sections and 12px gap between sections (ie. in practice, 12px gap between the status control and username). * Screenshot * Screenshots * Pass set status view model in the state rather than as an extra prop. Fixes it in the story too. * Pass ownprofilestore into the viewmodel * Don't use snapshot for testing the view model Avoids getting all the random stuff from the sub VM in the snapshot and better to test what we care about anyway. * don't mention the bad word * Here too * Don't show user status for guests * Update guest story to remove user status
This commit is contained in:
@@ -193,6 +193,16 @@
|
||||
},
|
||||
"unread_messages": "Unread messages"
|
||||
},
|
||||
"status": {
|
||||
"set_status": {
|
||||
"away": "Away",
|
||||
"be_right_back": "Be right back",
|
||||
"focus_time": "Focus Time",
|
||||
"in_a_meeting": "In a meeting",
|
||||
"on_the_road": "On the road",
|
||||
"set_status_prompt": "What's your status?"
|
||||
}
|
||||
},
|
||||
"terms": {
|
||||
"tac_button": "Review terms and conditions"
|
||||
},
|
||||
|
||||
@@ -91,5 +91,6 @@ export * from "./core/utils/ToastContext.tsx";
|
||||
export * from "./core/i18n/I18nApi";
|
||||
export * from "./core/utils/linkify";
|
||||
export type * from "./core/userStatus.ts";
|
||||
export * from "./status/SetStatusView";
|
||||
// MVVM
|
||||
export * from "./core/viewmodel";
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
/* Override menu defaults */
|
||||
padding-block: 0 !important;
|
||||
section.profile {
|
||||
margin: var(--cpd-space-8x) var(--cpd-space-6x);
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
margin-left: var(--cpd-space-6x);
|
||||
margin-right: var(--cpd-space-6x);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
@@ -48,6 +48,13 @@
|
||||
margin-bottom: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
.profilePrimary {
|
||||
margin-top: var(--cpd-space-8x);
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
}
|
||||
.profileSecondary {
|
||||
margin-bottom: var(--cpd-space-4x);
|
||||
}
|
||||
section.actions {
|
||||
margin: var(--cpd-space-2x) 0;
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
@@ -85,33 +92,6 @@
|
||||
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;
|
||||
|
||||
@@ -11,8 +11,14 @@ 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 { MockViewModel, useMockedViewModel } from "../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
import { type SetStatusViewSnapshot } from "../..";
|
||||
|
||||
class MockSetStatusViewModel extends MockViewModel<SetStatusViewSnapshot> {
|
||||
public setStatus = fn();
|
||||
public clearStatus = fn();
|
||||
}
|
||||
|
||||
const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
|
||||
const vm = useMockedViewModel<UserMenuViewSnapshot, UserMenuViewActions>(snapshot, {
|
||||
@@ -26,6 +32,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
|
||||
openSettings: fn(),
|
||||
clearStatus: fn(),
|
||||
});
|
||||
|
||||
return <UserMenuView vm={vm} />;
|
||||
};
|
||||
|
||||
@@ -60,11 +67,16 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
};
|
||||
|
||||
export const LongerName: Story = {
|
||||
args: {
|
||||
displayName: "Sally Sanderson with a longer name",
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,6 +102,7 @@ export const Open: Story = {
|
||||
userId: "@person-name:homeserver.com",
|
||||
expanded: true,
|
||||
showAvatar: true,
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
@@ -115,6 +128,7 @@ export const OpenVeryLongName: Story = {
|
||||
userId: "@person-whose-username-some-might-consider-to-be-a-little-overly-long-although-thats-their-choice-and-we-must-respect-it:homeserver.com",
|
||||
expanded: true,
|
||||
showAvatar: true,
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
@@ -132,6 +146,7 @@ export const Condensed: Story = {
|
||||
args: {
|
||||
displayName: "Sally Sanderson with a longer name",
|
||||
expanded: false,
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,6 +157,7 @@ export const NoAvatar: Story = {
|
||||
expanded: true,
|
||||
open: true,
|
||||
showAvatar: false,
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
parameters: Open.parameters,
|
||||
};
|
||||
@@ -152,6 +168,8 @@ export const Guest: Story = {
|
||||
userId: "@guest:attendees.example.org",
|
||||
manageAccountHref: undefined,
|
||||
showAvatar: false,
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
showUserStatus: false,
|
||||
actions: {
|
||||
createAccount: true,
|
||||
signIn: true,
|
||||
@@ -190,6 +208,7 @@ export const WithStatus: Story = {
|
||||
emoji: "🐹",
|
||||
text: "On the wheel",
|
||||
},
|
||||
setStatusViewModel: new MockSetStatusViewModel({}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -200,6 +219,12 @@ export const WithStatusOpen: Story = {
|
||||
emoji: "🐹",
|
||||
text: "On the wheel",
|
||||
},
|
||||
setStatusViewModel: new MockSetStatusViewModel({
|
||||
userStatus: {
|
||||
emoji: "🐹",
|
||||
text: "On the wheel",
|
||||
},
|
||||
}),
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { Avatar, Button, IconButton, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
ChatProblemIcon,
|
||||
DevicesIcon,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
LockIcon,
|
||||
PopOutIcon,
|
||||
SettingsIcon,
|
||||
CloseIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -22,7 +21,7 @@ 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 "../..";
|
||||
import { SetStatusView, type SetStatusViewModel } from "../../status/SetStatusView";
|
||||
|
||||
export interface UserMenuViewSnapshot {
|
||||
/**
|
||||
@@ -57,6 +56,16 @@ export interface UserMenuViewSnapshot {
|
||||
* The user status to display, or undefined for no icon / status.
|
||||
*/
|
||||
userStatus?: UserStatus;
|
||||
/**
|
||||
* Whether to show UI for user status.
|
||||
* Temporary while user status is in labs.
|
||||
* Default: true
|
||||
*/
|
||||
showUserStatus?: boolean;
|
||||
/**
|
||||
* ViewModel for the set status view.
|
||||
*/
|
||||
setStatusViewModel: SetStatusViewModel;
|
||||
/**
|
||||
* A set of actions that the user can perform from the menu.
|
||||
*/
|
||||
@@ -105,7 +114,7 @@ export declare interface UserMenuViewActions {
|
||||
*/
|
||||
openSettings: () => void;
|
||||
/**
|
||||
* Called when the user clicks the button to clear theirt status.
|
||||
* Called when the user clicks the button to clear their status.
|
||||
*/
|
||||
clearStatus: () => void;
|
||||
}
|
||||
@@ -118,30 +127,20 @@ 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, userStatus } =
|
||||
useViewModel(vm);
|
||||
const {
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
expanded,
|
||||
open,
|
||||
manageAccountHref,
|
||||
actions,
|
||||
showAvatar,
|
||||
userStatus,
|
||||
setStatusViewModel,
|
||||
showUserStatus = true,
|
||||
} = useViewModel(vm);
|
||||
const { translate: _t } = useI18n();
|
||||
const trigger = (
|
||||
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
|
||||
@@ -170,12 +169,14 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element
|
||||
side="right"
|
||||
className={styles.container}
|
||||
>
|
||||
<section className={styles.profile}>
|
||||
<section className={classNames(styles.profile, styles.profilePrimary)}>
|
||||
{showAvatar && <Avatar id={userId} name={displayName} type="round" size="64px" src={avatarUrl} />}
|
||||
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
|
||||
{displayName}
|
||||
</Text>
|
||||
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
|
||||
{showUserStatus && <SetStatusView vm={setStatusViewModel} />}
|
||||
</section>
|
||||
<section className={classNames(styles.profile, styles.profileSecondary)}>
|
||||
<Text data-testid="userId" size="md" as="span" type="body" className={styles.userId}>
|
||||
{userId}
|
||||
</Text>
|
||||
|
||||
+190
-2
@@ -286,13 +286,107 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<section
|
||||
class="UserMenu-module_profile"
|
||||
class="UserMenu-module_profile UserMenu-module_profilePrimary"
|
||||
>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 UserMenu-module_displayname"
|
||||
>
|
||||
Sally Sanderson
|
||||
</span>
|
||||
<div
|
||||
aria-invalid="false"
|
||||
class="_container_1xtut_8"
|
||||
>
|
||||
<div
|
||||
class="SetStatusView-module_setStatusContainer"
|
||||
>
|
||||
<a
|
||||
aria-controls="react-use-id-3"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="What's your status?"
|
||||
class="_link_k9ljz_8 SetStatusView-module_setStatusTrigger"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
rel="noreferrer noopener"
|
||||
role="combobox"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 3 3 0 0 1-2.12.879 3 3 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60"
|
||||
>
|
||||
What's your status?
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="_content_1xtut_62"
|
||||
>
|
||||
<ul
|
||||
class="_content_1xtut_62"
|
||||
id="react-use-id-3"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
💬 In a meeting
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
💡 Focus Time
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
🚙 On the road
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
☕️ Be right back
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
🌴 Away
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="UserMenu-module_profile UserMenu-module_profileSecondary"
|
||||
>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_userId"
|
||||
data-testid="userId"
|
||||
@@ -599,7 +693,7 @@ exports[`UserMenu > renders an open menu 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<section
|
||||
class="UserMenu-module_profile"
|
||||
class="UserMenu-module_profile UserMenu-module_profilePrimary"
|
||||
>
|
||||
<span
|
||||
aria-label="@person-name:homeserver.com"
|
||||
@@ -625,6 +719,100 @@ exports[`UserMenu > renders an open menu 1`] = `
|
||||
>
|
||||
Sally Sanderson with a longer name
|
||||
</span>
|
||||
<div
|
||||
aria-invalid="false"
|
||||
class="_container_1xtut_8"
|
||||
>
|
||||
<div
|
||||
class="SetStatusView-module_setStatusContainer"
|
||||
>
|
||||
<a
|
||||
aria-controls="react-use-id-3"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="What's your status?"
|
||||
class="_link_k9ljz_8 SetStatusView-module_setStatusTrigger"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
rel="noreferrer noopener"
|
||||
role="combobox"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 3 3 0 0 1-2.12.879 3 3 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60"
|
||||
>
|
||||
What's your status?
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="_content_1xtut_62"
|
||||
>
|
||||
<ul
|
||||
class="_content_1xtut_62"
|
||||
id="react-use-id-3"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
💬 In a meeting
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
💡 Focus Time
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
🚙 On the road
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
☕️ Be right back
|
||||
|
||||
</li>
|
||||
<li
|
||||
aria-selected="false"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
>
|
||||
🌴 Away
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="UserMenu-module_profile UserMenu-module_profileSecondary"
|
||||
>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_userId"
|
||||
data-testid="userId"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.setStatusContainer {
|
||||
/* Floats in a 28px tall box so it doesn't visually shift when it changes to the pill */
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.setStatusTrigger {
|
||||
text-decoration: underline solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: var(--cpd-space-1-5x);
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
place-items: start;
|
||||
gap: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
font-weight: var(--cpd-font-weight-medium);
|
||||
font-size: var(--cpd-font-size-body-md);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { SetStatusView, type SetStatusViewActions, type SetStatusViewSnapshot } from "./SetStatusView";
|
||||
import { useMockedViewModel } from "../core/viewmodel";
|
||||
import { withViewDocs } from "../../.storybook/withViewDocs";
|
||||
|
||||
const SetStatusViewWrapperImpl = ({
|
||||
setStatus,
|
||||
clearStatus,
|
||||
...snapshot
|
||||
}: SetStatusViewSnapshot & SetStatusViewActions): JSX.Element => {
|
||||
const vm = useMockedViewModel<SetStatusViewSnapshot, SetStatusViewActions>(snapshot, {
|
||||
setStatus,
|
||||
clearStatus,
|
||||
});
|
||||
return <SetStatusView vm={vm} />;
|
||||
};
|
||||
|
||||
const SetStatusViewWrapper = withViewDocs(SetStatusViewWrapperImpl, SetStatusView);
|
||||
|
||||
const meta = {
|
||||
title: "Status/SetStatusView",
|
||||
component: SetStatusViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
setStatus: fn(),
|
||||
clearStatus: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof SetStatusViewWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const NoStatus: Story = {};
|
||||
|
||||
export const WithStatus: Story = {
|
||||
args: {
|
||||
userStatus: { emoji: "🦩", text: "Flamboyant" },
|
||||
},
|
||||
};
|
||||
|
||||
// Rules needed for any story where the menu is open.
|
||||
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 = {
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: MENU_OPEN_A11Y_RULES,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["!dev", "!autodocs"],
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
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 { Dropdown, type DropdownTriggerProps, Link, Text } from "@vector-im/compound-web";
|
||||
import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t, _td, type UserStatus } from "..";
|
||||
import { useViewModel, type ViewModel } from "../core/viewmodel";
|
||||
import { StatusPillView } from "./StatusPillView";
|
||||
import styles from "./SetStatusView.module.css";
|
||||
|
||||
const PRESET_STATUSES = [
|
||||
{ emoji: "💬", textKey: _td("status|set_status|in_a_meeting") },
|
||||
{ emoji: "💡", textKey: _td("status|set_status|focus_time") },
|
||||
{ emoji: "🚙", textKey: _td("status|set_status|on_the_road") },
|
||||
{ emoji: "☕️", textKey: _td("status|set_status|be_right_back") },
|
||||
{ emoji: "🌴", textKey: _td("status|set_status|away") },
|
||||
];
|
||||
|
||||
export interface SetStatusViewSnapshot {
|
||||
/**
|
||||
* The current user status, or undefined if no status is set.
|
||||
*/
|
||||
userStatus?: UserStatus;
|
||||
}
|
||||
|
||||
export interface SetStatusViewActions {
|
||||
/**
|
||||
* Called when the user clicks to start setting a status.
|
||||
*
|
||||
* If falsy, the default dropdown will open for the user to choose a status.
|
||||
*/
|
||||
onSetStatusClick?: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user selects a preset status from the dropdown.
|
||||
*/
|
||||
setStatus: (status: UserStatus) => void;
|
||||
|
||||
/**
|
||||
* Called when the user clears their current status.
|
||||
*/
|
||||
clearStatus: () => void;
|
||||
}
|
||||
|
||||
export type SetStatusViewModel = ViewModel<SetStatusViewSnapshot, SetStatusViewActions>;
|
||||
|
||||
export type SetStatusViewProps = {
|
||||
vm: SetStatusViewModel;
|
||||
};
|
||||
|
||||
export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
|
||||
const { userStatus } = useViewModel(vm);
|
||||
|
||||
if (userStatus) {
|
||||
return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />;
|
||||
}
|
||||
|
||||
const renderTrigger = (props: DropdownTriggerProps): JSX.Element => {
|
||||
const trigger = (
|
||||
<div className={styles.setStatusContainer}>
|
||||
<Link
|
||||
className={styles.setStatusTrigger}
|
||||
aria-label={_t("status|set_status|set_status_prompt")}
|
||||
{...props}
|
||||
>
|
||||
<ReactionIcon />
|
||||
<Text as="span" type="body" size="md" weight="medium">
|
||||
{_t("status|set_status|set_status_prompt")}
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return trigger;
|
||||
};
|
||||
|
||||
const onValueChange = (value: string): void => {
|
||||
const status = PRESET_STATUSES.find((s) => s.textKey === value);
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
vm.setStatus({
|
||||
emoji: status.emoji,
|
||||
text: _t(status.textKey),
|
||||
});
|
||||
};
|
||||
|
||||
return vm.onSetStatusClick ? (
|
||||
renderTrigger({ onClick: vm.onSetStatusClick })
|
||||
) : (
|
||||
<Dropdown
|
||||
values={PRESET_STATUSES.map((s) => [s.textKey, `${s.emoji} ${_t(s.textKey)}`])}
|
||||
label={null}
|
||||
placeholder={null}
|
||||
trigger={renderTrigger}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.statusPill {
|
||||
border-radius: 30px;
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
display: inline-flex;
|
||||
/* Disables filling to available width if we're in a flex container */
|
||||
align-self: flex-start;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 { IconButton, Text } from "@vector-im/compound-web";
|
||||
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t, type UserStatus } from "..";
|
||||
import styles from "./StatusPillView.module.css";
|
||||
|
||||
export const StatusPillView = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
status: UserStatus;
|
||||
clearStatus: () => void;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(function StatusPillView({ status, clearStatus, ...props }, ref): JSX.Element {
|
||||
return (
|
||||
<div ref={ref} {...props} className={styles.statusPill}>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user