mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
fe8911861c
commit
c1db12e75e
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
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
@@ -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'>🐻</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>
|
||||
|
||||
Reference in New Issue
Block a user