Fix spacing in user status dropdown (#34589)

* Exploration of a virtuoso-powered emoji picker

moved to shared components

Fable generated

* fix pnpm lock

* format & fix some lint issues

* wrong import

* fix lint warning

* Fix off-by-one

and remove manual overflow adjustment: let's leave the default unless
it turns out to be necessary. Emoji should not take that long to load.

* Convert to functional component

* WIP: change to one big virtuoso scroller

* Change to use virtuoso's own onRangeChanged

and santitise category data and how it's passed around

* Convert Tabs to functional component

and put the focusing behaviour back with it just keeping track of
refs by itself.

* Absorb two line config file into main component

* Actually add the config to the main file

* Convert emoji to functional

Also make selected always defined and use useCallback.

* QuickReactions to functional component

* Non-default exports & doc

* Search to functional component

* Well it seems to work just fine now

* Use ref prop

* fix lockfile AGAIN

* lint

* Remove default export

* Remove some mx_ classnames and fix the inputRef

to make the arrow keys in the search box work (well, work as much as
they ever did).

* Remove last of the mx_ id / classnames

(except the one in the test)

* Use useMemo to memoize

* No need to export props interface (I think?)

and fix comment now we don't do the mutation stuff anymore

* Fix test

* Fix axe violations & add screenshots

* Avoid comparing dom snapshots in test

* Allow more before or after, just compare order of the ones present in both.

* Switch existing usages to new emoji picker

and kill the old one with fire

* Unused stuff

* Remove i18n strings

* Fix some tests

* Update screenshots

* Fix test

by removing the last of the weird memoized-but-mutated data structure

* Experimental custom status

* Screenshot

* snapshots

* Update button label and fix test

* Move the string somewhere more sensible than 'a11y'

* i18n lint

* Give the emojis IDs so aria-activedescendant works

* Fix more tests

* Add a small wrapper emoji picker component

This lets us easily memoize the recent emojis when the emoji picker is opened.
Also it saves a bit of boilerplate.

* Remove old emojipicker css

* Typos

Co-authored-by: David Langley <davidl@element.io>

* Use compound constants

* Rethemendex

* Test for custom status

* Use catalog version for emojibase

* Add comments

* More comments

* Fix comment

* More comments

* more comments (and make them uniform)

* More comments

* Fix pnpm lock again

* Another comment

* Add option to hide quick reactions (and preview) bar

* Fix test

to use emoji not in the quick reactions bar

* Apply button types to new version

* Add comment

* Disable screenshot

as per comment

* Fix hover / background / border styles

* Screenshot

* Don't use the reactions row because it's gone now

* Fix spacing in user status dropdown

Updates to renderItem based Dropdown, allowing the item in the dropdown
to be customised.

Requires https://github.com/element-hq/compound-web/pull/538 (and
will be required to update to the version of compound it gets
released in).

* Update to new compound

* snapshots

* Fix tests

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
Co-authored-by: David Langley <davidl@element.io>
This commit is contained in:
David Baker
2026-08-10 15:20:29 +00:00
committed by GitHub
co-authored by Will Hunt David Langley
parent 3a7be0d3a9
commit 3a196c7722
6 changed files with 173 additions and 40 deletions
@@ -345,7 +345,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
💬 In a meeting <span
class="SetStatusView-module_dropdownEmoji"
>
💬
</span>
<span>
In a meeting
</span>
</li> </li>
<li <li
@@ -353,7 +360,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
💡 Focus Time <span
class="SetStatusView-module_dropdownEmoji"
>
💡
</span>
<span>
Focus Time
</span>
</li> </li>
<li <li
@@ -361,7 +375,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
🚙 On the road <span
class="SetStatusView-module_dropdownEmoji"
>
🚙
</span>
<span>
On the road
</span>
</li> </li>
<li <li
@@ -369,7 +390,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
☕️ Be right back <span
class="SetStatusView-module_dropdownEmoji"
>
☕️
</span>
<span>
Be right back
</span>
</li> </li>
<li <li
@@ -377,7 +405,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
🌴 Away <span
class="SetStatusView-module_dropdownEmoji"
>
🌴
</span>
<span>
Away
</span>
</li> </li>
<li <li
@@ -385,7 +420,14 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
✍️ Custom… <span
class="SetStatusView-module_dropdownEmoji"
>
✍️
</span>
<span>
Custom…
</span>
</li> </li>
</ul> </ul>
@@ -779,7 +821,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
💬 In a meeting <span
class="SetStatusView-module_dropdownEmoji"
>
💬
</span>
<span>
In a meeting
</span>
</li> </li>
<li <li
@@ -787,7 +836,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
💡 Focus Time <span
class="SetStatusView-module_dropdownEmoji"
>
💡
</span>
<span>
Focus Time
</span>
</li> </li>
<li <li
@@ -795,7 +851,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
🚙 On the road <span
class="SetStatusView-module_dropdownEmoji"
>
🚙
</span>
<span>
On the road
</span>
</li> </li>
<li <li
@@ -803,7 +866,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
☕️ Be right back <span
class="SetStatusView-module_dropdownEmoji"
>
☕️
</span>
<span>
Be right back
</span>
</li> </li>
<li <li
@@ -811,7 +881,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
🌴 Away <span
class="SetStatusView-module_dropdownEmoji"
>
🌴
</span>
<span>
Away
</span>
</li> </li>
<li <li
@@ -819,7 +896,14 @@ exports[`UserMenu > renders an open menu 1`] = `
role="option" role="option"
tabindex="0" tabindex="0"
> >
✍️ Custom… <span
class="SetStatusView-module_dropdownEmoji"
>
✍️
</span>
<span>
Custom…
</span>
</li> </li>
</ul> </ul>
@@ -30,3 +30,7 @@ Please see LICENSE files in the repository root for full details.
font-weight: var(--cpd-font-weight-medium); font-weight: var(--cpd-font-weight-medium);
font-size: var(--cpd-font-size-body-md); font-size: var(--cpd-font-size-body-md);
} }
.dropdownEmoji {
margin-right: var(--cpd-space-1-5x);
}
@@ -24,7 +24,7 @@ class SetStatusViewModel extends MockViewModel<SetStatusViewSnapshot> implements
*/ */
async function openCustomEditor(): Promise<void> { async function openCustomEditor(): Promise<void> {
await userEvent.click(screen.getByRole("combobox")); await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "✍️ Custom…" })); await userEvent.click(screen.getByRole("option", { name: "✍️Custom…" }));
} }
describe("SetStatusView", () => { describe("SetStatusView", () => {
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, useState } from "react"; import React, { type JSX, useState, useCallback } from "react";
import { Dropdown, type DropdownTriggerProps, Link, Text } from "@vector-im/compound-web"; import { Dropdown, type DropdownTriggerProps, Link, Text } from "@vector-im/compound-web";
import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
@@ -15,16 +15,17 @@ import { StatusPillView } from "./StatusPillView";
import { CustomStatusView } from "./CustomStatusView"; import { CustomStatusView } from "./CustomStatusView";
import styles from "./SetStatusView.module.css"; import styles from "./SetStatusView.module.css";
const PRESET_STATUSES = [ const STATUSES = {
{ emoji: "💬", textKey: _td("status|set_status|in_a_meeting") }, in_a_meeting: { emoji: "💬", textKey: _td("status|set_status|in_a_meeting") },
{ emoji: "💡", textKey: _td("status|set_status|focus_time") }, focus_time: { emoji: "💡", textKey: _td("status|set_status|focus_time") },
{ emoji: "🚙", textKey: _td("status|set_status|on_the_road") }, on_the_road: { emoji: "🚙", textKey: _td("status|set_status|on_the_road") },
{ emoji: "☕️", textKey: _td("status|set_status|be_right_back") }, be_right_back: { emoji: "☕️", textKey: _td("status|set_status|be_right_back") },
{ emoji: "🌴", textKey: _td("status|set_status|away") }, away: { emoji: "🌴", textKey: _td("status|set_status|away") },
]; custom: { emoji: "✍️", textKey: _td("status|set_status|custom") },
};
// Sentinel value used to distinguish the "Custom…" dropdown entry from a preset. type StatusValue = keyof typeof STATUSES;
const CUSTOM_STATUS_VALUE = "custom"; // No need to keep recompyuting this, it won't change
const STATUS_KEYS = Object.keys(STATUSES) as StatusValue[];
export interface SetStatusViewSnapshot { export interface SetStatusViewSnapshot {
/** /**
@@ -58,10 +59,25 @@ export type SetStatusViewProps = {
vm: SetStatusViewModel; vm: SetStatusViewModel;
}; };
function StatusOption({ value }: { value: StatusValue }): React.ReactNode {
return (
<>
<span className={styles.dropdownEmoji}>{STATUSES[value].emoji}</span>
<span>{_t(STATUSES[value].textKey)}</span>
</>
);
}
export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element { export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
const { userStatus } = useViewModel(vm); const { userStatus } = useViewModel(vm);
const [customMode, setCustomMode] = useState(false); const [customMode, setCustomMode] = useState(false);
const renderItem = useCallback((value: StatusValue | null): React.ReactNode => {
if (value === null) return null;
return <StatusOption value={value} />;
}, []);
if (userStatus) { if (userStatus) {
return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />; return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />;
} }
@@ -97,13 +113,13 @@ export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
return trigger; return trigger;
}; };
const onValueChange = (value: string): void => { const onValueChange = (value: StatusValue): void => {
if (value === CUSTOM_STATUS_VALUE) { if (value === "custom") {
setCustomMode(true); setCustomMode(true);
return; return;
} }
const status = PRESET_STATUSES.find((s) => s.textKey === value); const status = STATUSES[value];
if (!status) { if (!status) {
return; return;
@@ -115,20 +131,15 @@ export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
}); });
}; };
const dropdownValues: Array<[string, string]> = [
...PRESET_STATUSES.map((s): [string, string] => [s.textKey, `${s.emoji} ${_t(s.textKey)}`]),
[CUSTOM_STATUS_VALUE, `✍️ ${_t("status|set_status|custom")}`],
];
return vm.onSetStatusClick ? ( return vm.onSetStatusClick ? (
renderTrigger({ onClick: vm.onSetStatusClick }) renderTrigger({ onClick: vm.onSetStatusClick })
) : ( ) : (
<Dropdown <Dropdown<StatusValue>
values={dropdownValues} values={STATUS_KEYS}
label={null} label={null}
placeholder={null}
trigger={renderTrigger} trigger={renderTrigger}
onValueChange={onValueChange} onValueChange={onValueChange}
renderItem={renderItem}
/> />
); );
} }
+38 -4
View File
@@ -222,8 +222,8 @@ catalogs:
specifier: 10.2.1 specifier: 10.2.1
version: 10.2.1 version: 10.2.1
'@vector-im/compound-web': '@vector-im/compound-web':
specifier: 9.9.0 specifier: 10.0.0
version: 9.9.0 version: 10.0.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: 6.0.5 specifier: 6.0.5
version: 6.0.5 version: 6.0.5
@@ -557,7 +557,7 @@ importers:
version: 10.2.1(@types/react@19.2.18)(react@19.2.8) version: 10.2.1(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web': '@vector-im/compound-web':
specifier: 'catalog:' specifier: 'catalog:'
version: 9.9.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8) version: 10.0.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8)
'@vector-im/matrix-wysiwyg': '@vector-im/matrix-wysiwyg':
specifier: 2.40.0 specifier: 2.40.0
version: 2.40.0(patch_hash=7bdf6150f2905bc2f055a6bcaa7b9d78fa7ffde82e800bcc454ac7b0096bd65e)(react@19.2.8) version: 2.40.0(patch_hash=7bdf6150f2905bc2f055a6bcaa7b9d78fa7ffde82e800bcc454ac7b0096bd65e)(react@19.2.8)
@@ -1501,7 +1501,7 @@ importers:
version: typescript@7.0.2 version: typescript@7.0.2
'@vector-im/compound-web': '@vector-im/compound-web':
specifier: 'catalog:' specifier: 'catalog:'
version: 9.9.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8) version: 10.0.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: 'catalog:' specifier: 'catalog:'
version: 6.0.5(vite@8.2.0) version: 6.0.5(vite@8.2.0)
@@ -6572,6 +6572,18 @@ packages:
react: react:
optional: true optional: true
'@vector-im/compound-web@10.0.0':
resolution: {integrity: sha512-Gdxi+MjOTds1QwXw1bFy6NQnTgynNeSCmmOS7Q9c7pogaZQlpEYaEu6mPXww6T6TwBRgK7MNDXvF3k5C8h653Q==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
'@types/react': ^19.2.10
'@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
react: ^18 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@vector-im/compound-web@9.9.0': '@vector-im/compound-web@9.9.0':
resolution: {integrity: sha512-GjAEhzB/mt9MA7Y1ySe6/AlW26vodDu4hvTi1ThB5rTrt4KAiHMpvk+T56KV5Lwo0VvBxAyHMtm/IY5tkyH12A==} resolution: {integrity: sha512-GjAEhzB/mt9MA7Y1ySe6/AlW26vodDu4hvTi1ThB5rTrt4KAiHMpvk+T56KV5Lwo0VvBxAyHMtm/IY5tkyH12A==}
peerDependencies: peerDependencies:
@@ -19112,6 +19124,28 @@ snapshots:
'@types/react': 19.2.18 '@types/react': 19.2.18
react: 19.2.8 react: 19.2.8
'@vector-im/compound-web@10.0.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.8)(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8)(react@19.2.8)
'@fontsource/inconsolata': 5.2.8
'@fontsource/inter': 5.3.0
'@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
'@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
'@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
'@radix-ui/react-progress': 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
'@radix-ui/react-separator': 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 10.2.1(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1
react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.3)(@types/react@19.2.18)(react-dom@19.2.8)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
transitivePeerDependencies:
- '@types/react-dom'
- react-dom
'@vector-im/compound-web@9.9.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8)': '@vector-im/compound-web@9.9.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3)(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.1)(react-dom@19.2.8)(react@19.2.8)':
dependencies: dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.8)(react@19.2.8) '@floating-ui/react': 0.27.17(react-dom@19.2.8)(react@19.2.8)
+1 -1
View File
@@ -27,7 +27,7 @@ catalog:
"playwright-core": 1.61.1 "playwright-core": 1.61.1
# Compound # Compound
"@vector-im/compound-design-tokens": 10.2.1 "@vector-im/compound-design-tokens": 10.2.1
"@vector-im/compound-web": 9.9.0 "@vector-im/compound-web": 10.0.0
# i18n # i18n
matrix-web-i18n: 3.6.0 matrix-web-i18n: 3.6.0
# emojibase # emojibase