💅(frontend) refine mailbox dropdown menu (#705)

Customize mailbox dropdown menu to make it more 'interactible'.
Distinguish shared vs personal mailbox through avatar shape and
display Name + email.
This commit is contained in:
Jean-Baptiste PENRATH
2026-06-12 00:16:50 +02:00
committed by GitHub
parent ff01cf42da
commit 5d12e61644
14 changed files with 272 additions and 240 deletions
@@ -71,8 +71,12 @@ test.describe("Mailbox settings modal", () => {
// The user administers two mailboxes (their own + the import mailbox) and is
// only a sender on the shared mailbox, so the switcher is rendered and lists
// exactly the two administered mailboxes.
//
// The switcher trigger is the shared MailboxSelector card: its accessible
// name is the currently-configured mailbox (the user's own administered
// mailbox) rather than a fixed label.
await modal
.getByRole("button", { name: "Select the mailbox to configure" })
.getByRole("button", { name: `user.e2e.${browserName}@example.local` })
.click();
// The switcher renders a single-select dropdown, so its entries expose a
+3 -2
View File
@@ -23,7 +23,7 @@ test.describe("Import Message", () => {
// Go the import mailbox
await page.getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
await page.getByRole("menuitem", { name: getMailboxEmail('import') }).click();
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('import') }).click();
await page.waitForLoadState("networkidle");
// As the database is fresh, there should be no threads and the Import messages button should be visible
@@ -117,7 +117,8 @@ test.describe("Import Message", () => {
// Go to the shared mailbox where the user only has sender rights
await page.getByRole("button", { name: email }).click();
await page
.getByRole("menuitem", { name: getMailboxEmail("shared") })
.getByRole("menuitem")
.filter({ hasText: getMailboxEmail("shared") })
.click();
await page.waitForLoadState("networkidle");
@@ -112,7 +112,7 @@ test.describe("Inline Image in Composer", () => {
// Switch to shared mailbox and verify the message is received
// Message delivery is async (Celery task), so we poll until the message appears
await page.getByTestId('panel-main-left').getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
await page.getByRole("menuitem", { name: getMailboxEmail('shared') }).click();
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('shared') }).click();
await page.waitForLoadState("networkidle");
await page.getByRole("link", { name: "Inbox" }).click();
+1 -1
View File
@@ -59,7 +59,7 @@ test.describe("Send Message", () => {
// Go the shared mailbox and check if the message is there
await page.getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
await page.getByRole("menuitem", { name: getMailboxEmail('shared') }).click();
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('shared') }).click();
await page.waitForLoadState("networkidle");
await page.getByRole("link", { name: "Inbox" }).click();
+2 -1
View File
@@ -33,7 +33,8 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) {
.getByRole("button", { name: getMailboxEmail("user", browserName) })
.click();
await page
.getByRole("menuitem", { name: getMailboxEmail("shared") })
.getByRole("menuitem")
.filter({ hasText: getMailboxEmail("shared") })
.click();
await page.waitForLoadState("networkidle");
+11 -3
View File
@@ -68,9 +68,17 @@ export const signInKeycloakIfNeeded = async ({ page, username, navigateTo = "/"
await page.click('button[type="submit"]');
await page.waitForURL(`/`, { waitUntil: 'networkidle' });
expect(proConnectButton).not.toBeVisible();
const mailboxName = await page.getByRole('button', { name: email });
expect(mailboxName).toBeVisible();
await expect(proConnectButton).not.toBeVisible();
// Confirm the authenticated app shell rendered before snapshotting storage
// state. The sidebar mailbox selector shows the signed-in address, but it
// renders as a switcher *button* only for multi-mailbox users; single-mailbox
// fixtures (e.g. domain_admin) get a static card instead. Match on the
// address text within the selector rather than a button role, which covers
// both variants.
await expect(
page.locator('.mailbox-selector').getByText(email),
).toBeVisible();
await page.context().storageState({ path: storageStatePath });
};
@@ -23,33 +23,11 @@
flex-shrink: 0;
padding-bottom: var(--c--globals--spacings--xs);
& > .c__dropdown-menu-trigger {
justify-content: flex-start;
width: 100%;
}
button.mailbox-panel__mailbox-title__dropdown-button {
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 700;
padding-block: var(--c--globals--spacings--sm);
// The trigger card itself lives in the shared MailboxSelector component; here
// we only nudge its trailing padding so the unfold chevron lines up with the
// action icons sitting above it in the sidebar header.
.mailbox-selector__trigger {
// 1.1rem hardcoded to be aligned with other icon in the sidebar
padding-inline: var(--c--globals--spacings--base) 1.1rem;
width: 100%;
height: inherit;
& > .button__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
justify-content: flex-start;
}
&:hover,
&:focus,
&:active {
color: inherit;
}
padding-inline-end: 1.1rem;
}
}
@@ -1,68 +1,40 @@
import { DropdownMenu, HorizontalSeparator, Icon, Spinner } from "@gouvfr-lasuite/ui-kit"
import { ChevronDown, ChevronUp } from "@gouvfr-lasuite/ui-kit/icons";
import { HorizontalSeparator, Spinner } from "@gouvfr-lasuite/ui-kit"
import { MailboxPanelActions } from "./components/mailbox-actions"
import { MailboxList } from "./components/mailbox-list"
import { useMailboxContext } from "@/features/providers/mailbox";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useNavigate } from "@tanstack/react-router";
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
import { useLayoutContext } from "@/features/layouts/components/layout-context";
import { MailboxLabels } from "./components/mailbox-labels";
import { useState } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
import MailboxHelper from "@/features/utils/mailbox-helper";
import { MailboxSelector } from "@/features/layouts/components/mailbox-selector";
export const MailboxPanel = () => {
const navigate = useNavigate();
const searchParams = useUrlSearchParams();
const { selectedMailbox, mailboxes, queryStates } = useMailboxContext();
const { closeLeftPanel } = useLayoutContext();
const [isOpen, setIsOpen] = useState(false);
const { defaultLayout, onLayoutChange } = useDefaultLayout({
groupId: "mailbox-panel-sections",
storage: typeof window !== "undefined" ? localStorage : undefined,
});
const getMailboxOptions = () => {
if (!mailboxes) return [];
const sortedMailboxes = MailboxHelper.sortByKind(mailboxes);
return sortedMailboxes.map((mailbox, index) => ({
label: mailbox.email,
value: mailbox.id,
icon: mailbox.is_identity ? <Icon name="person" /> : <Icon name="group" />,
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index)
}));
}
return (
<div className="mailbox-panel">
<div className="mailbox-panel__header">
<MailboxPanelActions />
<HorizontalSeparator withPadding={false} />
{ selectedMailbox && (
<div className="mailbox-panel__mailbox-title">
<DropdownMenu
options={getMailboxOptions()}
isOpen={isOpen}
onOpenChange={setIsOpen}
selectedValues={[selectedMailbox.id]}
onSelectValue={(value) => {
closeLeftPanel();
navigate({ to: '/mailbox/$mailboxId', params: { mailboxId: value }, search: Object.fromEntries(searchParams) });
}}
>
<Button
className="mailbox-panel__mailbox-title__dropdown-button"
color="neutral"
variant="tertiary"
icon={isOpen ? <ChevronUp size="small" /> : <ChevronDown size="small" />}
iconPosition="right"
onClick={() => setIsOpen(!isOpen)}
>
<span className="button__label">{selectedMailbox.email}</span>
</Button>
</DropdownMenu>
</div>
{ selectedMailbox && mailboxes && (
<div className="mailbox-panel__mailbox-title">
<MailboxSelector
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onSelect={(mailboxId) => {
closeLeftPanel();
navigate({ to: '/mailbox/$mailboxId', params: { mailboxId }, search: Object.fromEntries(searchParams) });
}}
/>
</div>
)}
</div>
{!selectedMailbox || queryStates.mailboxes.isLoading ? <Spinner /> :
@@ -0,0 +1,99 @@
.mailbox-selector {
// Make the dropdown trigger wrapper span the full available width so the
// card aligns with the surrounding sidebar/header content.
& > .c__dropdown-menu-trigger {
justify-content: flex-start;
width: 100%;
}
// Card shared by the switchable (button) and the static (single-mailbox)
// variants: avatar on the left, bold name and the address underneath, with
// the unfold chevron pushed to the trailing edge.
.mailbox-selector__trigger {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--xs);
width: 100%;
height: inherit;
padding-block: var(--c--globals--spacings--base) var(--c--globals--spacings--sm);
padding-inline: var(--c--globals--spacings--base);
.c__button__icon {
transition: transform 0.25s ease;
backface-visibility: visible;
}
}
.mailbox-selector__trigger[aria-expanded="true"] {
.c__button__icon {
transform: rotateX(-180deg);
}
}
.mailbox-selector__trigger--static {
// Nothing to unfold for a single mailbox: drop the interactive cursor.
cursor: default;
}
// Shared (non-identity) mailboxes get a rounded-square avatar to set them
// apart from the circular personal one.
.mailbox-selector__avatar[data-shared="true"] .c__avatar {
border-radius: 8px;
}
.mailbox-selector__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
text-align: left;
}
.mailbox-selector__name {
font-size: var(--c--globals--font--sizes--sm);
line-height: 1.2;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-selector__email {
font-size: var(--c--globals--font--sizes--xs);
opacity: 0.8;
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Same rounded-square treatment for shared mailboxes in the dropdown options.
// The popover renders in a portal outside `.mailbox-selector`, so this rule is
// intentionally global rather than nested under it.
.mailbox-selector__option-avatar {
// Layout-transparent wrapper: the avatar sits exactly where the dropdown
// expects its icon, we only need the element as a styling hook.
display: contents;
&[data-shared="true"] .c__avatar {
border-radius: 8px;
}
}
// The dropdown caps its width at 320px, so a long mailbox name would otherwise
// wrap onto several lines. Truncate it with an ellipsis instead. Scoped to our
// items (which carry the avatar hook) since the popover lives in a portal.
.c__dropdown-menu-item:has(.mailbox-selector__option-avatar) {
.c__dropdown-menu-item__label-container {
min-width: 0;
}
.c__dropdown-menu-item__label,
.c__dropdown-menu-item__label-subtext {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -0,0 +1,113 @@
import { DropdownMenu, UserAvatar } from "@gouvfr-lasuite/ui-kit";
import { ChevronDown } from "@gouvfr-lasuite/ui-kit/icons";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useState } from "react";
import { Mailbox } from "@/features/api/gen";
import MailboxHelper from "@/features/utils/mailbox-helper";
/** Display name of a mailbox, falling back to its address when no contact name
* is set (a mailbox may legitimately have a null/blank name). */
const getMailboxLabel = (mailbox: Mailbox) => mailbox.name?.trim() || mailbox.email;
type MailboxSelectorProps = {
/** Mailboxes the user can switch to (already the eligible subset). */
mailboxes: readonly Mailbox[];
/** Currently displayed mailbox. */
selectedMailbox: Mailbox;
/** Called with the picked mailbox id; never fired for the current one. */
onSelect: (mailboxId: string) => void | Promise<void>;
};
/**
* Mailbox switcher shared by the sidebar header and the settings modal: an
* avatar + bold name + address card that unfolds a dropdown of the eligible
* mailboxes. When the user owns a single mailbox there is nothing to switch to,
* so the card renders as static content (not a disabled button) to keep the
* name/address legible and avoid exposing a bogus "button, unavailable" control.
*/
export const MailboxSelector = ({
mailboxes,
selectedMailbox,
onSelect,
}: MailboxSelectorProps) => {
const [isOpen, setIsOpen] = useState(false);
const label = getMailboxLabel(selectedMailbox);
const sublabel = selectedMailbox.name?.trim() ? selectedMailbox.email : null;
const canSwitch = mailboxes.length > 1;
// Avatar is decorative: the name it encodes is already shown next to it as
// text, so hide it from assistive tech to avoid a duplicate announcement.
const content = (
<>
<span
className="mailbox-selector__avatar"
data-shared={!selectedMailbox.is_identity}
aria-hidden="true"
>
<UserAvatar fullName={label} size="small" />
</span>
<span className="mailbox-selector__text">
<span className="mailbox-selector__name">{label}</span>
{sublabel && <span className="mailbox-selector__email">{sublabel}</span>}
</span>
</>
);
if (!canSwitch) {
return (
<div className="mailbox-selector">
<div className="mailbox-selector__trigger mailbox-selector__trigger--static">
{content}
</div>
</div>
);
}
const sortedMailboxes = MailboxHelper.sortByKind(mailboxes);
const options = sortedMailboxes.map((mailbox, index) => ({
label: getMailboxLabel(mailbox),
subText: mailbox.name?.trim() ? mailbox.email : undefined,
value: mailbox.id,
icon: (
<span
className="mailbox-selector__option-avatar"
data-shared={!mailbox.is_identity}
>
<UserAvatar fullName={getMailboxLabel(mailbox)} size="small" />
</span>
),
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index),
}));
return (
<div className="mailbox-selector">
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={setIsOpen}
selectedValues={[selectedMailbox.id]}
onSelectValue={(value) => {
setIsOpen(false);
if (value !== selectedMailbox.id) {
void onSelect(value);
}
}}
>
<Button
className="mailbox-selector__trigger"
color="brand"
variant="tertiary"
icon={<ChevronDown />}
iconPosition="right"
aria-haspopup="menu"
aria-expanded={isOpen}
fullWidth
onClick={() => setIsOpen(!isOpen)}
>
{content}
</Button>
</DropdownMenu>
</div>
);
};
@@ -36,78 +36,14 @@
// macOS-style account card sitting at the very top of the settings sidebar:
// mailbox avatar on the left, name in bold and (when set) the address in a
// muted line underneath.
// Wrapper around the shared MailboxSelector in the sidebar header. Full width
// with min-width:0 so the selector's text column can shrink and ellipsize the
// long address instead of overflowing the narrow sidebar.
&__identity {
/* gap: var(--c--globals--spacings--xs); */
width: 100%;
// Min-width:0 lets the flexible text column actually shrink so the long
// address can ellipsize instead of overflowing the narrow sidebar.
min-width: 0;
}
&__identity-text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
line-height: 1.25;
}
&__identity-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
font-size: var(--c--globals--font--sizes--sm);
font-weight: 600;
color: var(--c--contextuals--content--semantic--neutral--primary);
}
&__identity-email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--c--globals--font--sizes--xs);
font-weight: 400;
min-width: 0;
max-width: 100%;
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
// Shared box for both card variants so the switchable (button) and the
// static (single-mailbox) renderings keep the exact same footprint.
&__dropdown-button,
&__identity-static {
padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--xs);
}
// Switchable variant: the whole card becomes a borderless button with an
// unfold chevron and a subtle hover fill, mirroring a macOS popup button.
&__dropdown-button {
border: none;
height: inherit;
justify-content: space-between;
}
&__dropdown-button__content {
display: flex;
align-items: center;
gap: var(--c--globals--spacings--s);
// Let the content shrink inside the (full-width) card so the text column
// can ellipsize instead of pushing the card wider than the sidebar.
min-width: 0;
// The avatar keeps its size; only the text column absorbs the shrink.
& > :first-child {
flex-shrink: 0;
}
}
&__identity-chevron {
margin-left: auto;
flex-shrink: 0;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
&__section {
margin-bottom: var(--c--globals--spacings--lg);
}
@@ -1,16 +1,14 @@
import {
Button,
Modal,
ModalSize,
ModalTab,
} from "@gouvfr-lasuite/cunningham-react";
import { DropdownMenu, HorizontalSeparator, UserAvatar } from "@gouvfr-lasuite/ui-kit";
import { HorizontalSeparator } from "@gouvfr-lasuite/ui-kit";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Mailbox } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
import MailboxHelper from "@/features/utils/mailbox-helper";
import { MailboxSelector } from "@/features/layouts/components/mailbox-selector";
import {
useConfirmBeforeClose,
useConfirmUnsavedChanges,
@@ -21,7 +19,6 @@ import { MailboxSettingsSignaturesTab } from "./signatures-tab";
import { MailboxSettingsMessageTemplatesTab } from "./message-templates-tab";
import { MailboxSettingsAutorepliesTab } from "./autoreplies-tab";
import { MailboxSettingsIntegrationsTab } from "./integrations-tab";
import { ChevronDown, ChevronUp } from "@gouvfr-lasuite/ui-kit/icons";
export type SettingsTabId =
| "general"
@@ -45,11 +42,6 @@ export const MODAL_MAILBOX_SETTINGS_ID = "modal-mailbox-settings";
// the sidebar↔content view itself can't be driven from outside the component.
const COMPACT_MODAL_MEDIA_QUERY = "(max-width: 576px)";
/** Display name shown on the identity card; falls back to the address when the
* mailbox has no contact name set. */
const getMailboxName = (mailbox: Mailbox) =>
mailbox.name?.trim() || mailbox.email;
/**
* Settings modal for a mailbox the user can configure. Built on Cunningham's
* "tab" modal layout: the sidebar lists setting categories (General — rename —,
@@ -91,7 +83,6 @@ export const ModalMailboxSettings = ({
const [selectedMailboxId, setSelectedMailboxId] = useState<string | null>(
null,
);
const [isMailboxDropdownOpen, setIsMailboxDropdownOpen] = useState(false);
const [isActiveTabDirty, setIsActiveTabDirty] = useState(false);
const confirmUnsavedChanges = useConfirmUnsavedChanges();
@@ -130,18 +121,6 @@ export const ModalMailboxSettings = ({
: "general";
});
const mailboxOptions = useMemo(() => {
const sortedMailboxes = MailboxHelper.sortByKind(settingsMailboxes);
return sortedMailboxes.map((mailbox: Mailbox, index) => ({
label: getMailboxName(mailbox),
subText: mailbox.name?.trim() ? mailbox.email : undefined,
value: mailbox.id,
icon: <UserAvatar fullName={getMailboxName(mailbox)} size="small" />,
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index),
}));
}, [settingsMailboxes]);
// Ordered ids of the tabs the selected mailbox exposes, gated by its abilities
// (and the integrations feature flag). Single source of truth: it drives both
// the rendered `tabs` below and the active-tab synchronisation on mailbox
@@ -191,81 +170,20 @@ export const ModalMailboxSettings = ({
return null;
}
const hasMultipleMailboxes = settingsMailboxes.length > 1;
const mailboxName = getMailboxName(settingsMailbox);
const mailboxSubtitle = settingsMailbox.name?.trim()
? settingsMailbox.email
: null;
// The macOS-style account card: avatar, name and (when distinct) the address
// underneath. Shared between the static and the switchable variants below.
const identityCardContent = (
<>
<UserAvatar fullName={mailboxName} size="medium" />
<span className="mailbox-settings__identity-text">
<span className="mailbox-settings__identity-name">{mailboxName}</span>
{mailboxSubtitle && (
<span className="mailbox-settings__identity-email">
{mailboxSubtitle}
</span>
)}
</span>
</>
);
// macOS-style account card switching the configured mailbox, shared with the
// sidebar header switcher. Switching remounts the General tab and discards any
// unsaved rename, so confirm first when there are pending edits.
const sidebarHeader = (
<>
{hasMultipleMailboxes ? (
<DropdownMenu
options={mailboxOptions}
isOpen={isMailboxDropdownOpen}
onOpenChange={setIsMailboxDropdownOpen}
selectedValues={[settingsMailbox.id]}
onSelectValue={async (value) => {
setIsMailboxDropdownOpen(false);
if (value === settingsMailbox.id) {
return;
}
// Switching the configured mailbox remounts the General tab and
// discards any unsaved rename, just like a tab switch does.
if (await confirmUnsavedChanges(isActiveTabDirty)) {
setSelectedMailboxId(value);
}
}}
>
<Button
className="mailbox-settings__dropdown-button"
variant="tertiary"
color="neutral"
aria-label={t("Select the mailbox to configure")}
aria-expanded={isMailboxDropdownOpen}
fullWidth
onClick={() => setIsMailboxDropdownOpen(!isMailboxDropdownOpen)}
icon={
isMailboxDropdownOpen ? (
<ChevronUp size="small" />
) : (
<ChevronDown size="small" />
)
}
iconPosition="right"
>
<div className="mailbox-settings__dropdown-button__content">
{identityCardContent}
</div>
</Button>
</DropdownMenu>
) : (
// Single mailbox: nothing to switch to, so the card is purely informative.
// Rendered as static content (not a disabled button) to avoid exposing a
// bogus "button, unavailable" control and to keep the name/address fully
// legible instead of greyed out by the disabled state.
<div className="mailbox-settings__identity-static">
<div className="mailbox-settings__dropdown-button__content">
{identityCardContent}
</div>
</div>
)}
<MailboxSelector
mailboxes={settingsMailboxes}
selectedMailbox={settingsMailbox}
onSelect={async (value) => {
if (await confirmUnsavedChanges(isActiveTabDirty)) {
setSelectedMailboxId(value);
}
}}
/>
<HorizontalSeparator width="double" />
</>
);
+1
View File
@@ -16,6 +16,7 @@ body {
margin: 0;
background-color: var(--c--contextuals--background--surface--tertiary);
color: var(--c--contextuals--content--semantic--neutral--primary);
perspective: 9000px;
}
* {
+1
View File
@@ -30,6 +30,7 @@
@use "./../features/ui/components/suggestion-input";
@use "./../features/ui/components/transient-tooltip";
@use "./../features/ui/components/assignees-avatar-group";
@use "./../features/layouts/components/mailbox-selector";
@use "./../features/layouts/components/mailbox-panel";
@use "./../features/layouts/components/mailbox-panel/components/mailbox-actions";
@use "./../features/layouts/components/mailbox-panel/components/mailbox-list";