feat(web): account menu on Radix DropdownMenu (non-modal for legacy click-through parity)

Ids preserved (#account-btn/#account-menu/#menu-*/#signout); Radix owns
Escape/outside dismissal + focus; dismissal behavior pinned by spec first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 12:47:47 -07:00
co-authored by Claude Fable 5
parent fe8911861c
commit c1db12e75e
7 changed files with 111 additions and 84 deletions
+12
View File
@@ -91,3 +91,15 @@ test("new project via the sidebar + modal", async ({ page }) => {
await expect(page.locator("#project-select option")).toContainText(["scratch", "wiki"]);
await expectToast(page, "Created");
});
test("account menu closes on Escape and outside click", async ({ page }) => {
await login(page);
await page.click("#account-btn");
await expect(page.locator("#account-menu")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#account-menu")).toHaveCount(0);
await page.click("#account-btn");
await expect(page.locator("#account-menu")).toBeVisible();
await page.click("#content", { position: { x: 10, y: 10 } });
await expect(page.locator("#account-menu")).toHaveCount(0);
});
@@ -1,11 +1,18 @@
import { useEffect, useRef, useState } from "react";
import type { Org } from "../api/types";
import { Icon } from "./shell";
import { projColor } from "./ProjectNav";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// The sidebar footer is the account row: avatar, name, email. Clicking it
// opens a popover with the workspace (org) and account actions — settings,
// hub administration for admins, and sign-out.
// opens a menu with the workspace (org) and account actions — settings,
// hub administration for admins, and sign-out. Radix owns open/dismiss
// behavior (Escape, outside click, focus).
export function AccountBar({
me,
org,
@@ -17,86 +24,52 @@ export function AccountBar({
admin?: { pending: number; onClick: () => void }; // hub admins only
onOrgSettings: (org: Org) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const display = me.name || me.email;
return (
<footer id="accountbar" ref={ref}>
{open && (
<div id="account-menu" role="menu" aria-label="Account menu">
<footer id="accountbar">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button id="account-btn" aria-label="Account menu">
<span className="avatar" style={{ background: projColor(me.email) }} aria-hidden="true">
{(display.trim()[0] || "?").toUpperCase()}
</span>
<span className="acct">
<b>{display}</b>
{me.name && <small>{me.email}</small>}
</span>
<Icon name="chev" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent id="account-menu" side="top" align="start" sideOffset={6} className="acct-menu">
{org && (
<>
<div className="menu-sec">Organization</div>
<button
id="menu-org-settings"
role="menuitem"
onClick={() => {
setOpen(false);
onOrgSettings(org);
}}
>
<DropdownMenuLabel className="menu-sec">Organization</DropdownMenuLabel>
<DropdownMenuItem id="menu-org-settings" onSelect={() => onOrgSettings(org)}>
<Icon name="gear" />
<span>
<b>{org.name}</b> Settings
</span>
</button>
</DropdownMenuItem>
</>
)}
{admin && (
<>
<div className="menu-sec">Hub</div>
<button
id="menu-hub-admin"
role="menuitem"
onClick={() => {
setOpen(false);
admin.onClick();
}}
>
<DropdownMenuLabel className="menu-sec">Hub</DropdownMenuLabel>
<DropdownMenuItem id="menu-hub-admin" onSelect={admin.onClick}>
<Icon name="shield" />
<span>Signup &amp; access{admin.pending ? ` · ${admin.pending}` : ""}</span>
</button>
</DropdownMenuItem>
</>
)}
<div className="menu-sec">Account</div>
<a id="signout" role="menuitem" href="/auth/logout">
<Icon name="power" />
<span>Log out</span>
</a>
</div>
)}
<button
id="account-btn"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className="avatar" style={{ background: projColor(me.email) }} aria-hidden="true">
{(display.trim()[0] || "?").toUpperCase()}
</span>
<span className="acct">
<b>{display}</b>
{me.name && <small>{me.email}</small>}
</span>
<Icon name="chev" />
</button>
<DropdownMenuLabel className="menu-sec">Account</DropdownMenuLabel>
<DropdownMenuItem asChild>
<a id="signout" href="/auth/logout">
<Icon name="power" />
<span>Log out</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</footer>
);
}
+5 -3
View File
@@ -177,11 +177,13 @@ button, input, a.btn { font-family: inherit; }
#account-btn .acct small { font-size: 11px; color: var(--text-ghost); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#account-btn > .ico { width: 14px; height: 14px; color: var(--text-ghost); }
#account-menu {
position: absolute; left: 10px; right: 10px; bottom: calc(100% + 4px);
min-width: var(--radix-dropdown-menu-trigger-width, 220px);
padding: 5px; border: 1px solid var(--border-2); border-radius: 9px;
background: var(--surface); box-shadow: 0 10px 32px rgba(0, 0, 0, .35);
display: flex; flex-direction: column; z-index: 30;
background: var(--bg-raise); box-shadow: 0 10px 32px rgba(0, 0, 0, .35);
display: flex; flex-direction: column; z-index: 80; outline: none;
}
#account-menu [role="menuitem"] { outline: none; }
#account-menu [role="menuitem"][data-highlighted] { background: var(--hover); color: var(--text); }
#account-menu .menu-sec {
padding: 7px 9px 3px; font-size: 10.5px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--text-ghost);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-BLeSJqYs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGscFs1l.css">
<script type="module" crossorigin src="/assets/index-D7_o95eD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFnn1reh.css">
</head>
<body>
<div id="root"></div>