import { type KeyboardEvent, type ReactElement, useCallback, useEffect, useId, useRef, useState, } from "react"; import type { DashboardDestination } from "../dashboard-routes.js"; import type { HostAccount } from "../host-protocol.js"; import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js"; import { DashboardLink } from "./DashboardLink.js"; import type { HostHistory } from "./host-history.js"; import { useNavTooltip } from "./NavTooltip.js"; import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js"; import { ShellIcon, type ShellIconName } from "./shell-icons.js"; export interface AccountMenuProps { readonly presentation: NavigationPresentation; readonly history: HostHistory; /** Who is signed in, or `"unavailable"` when their account could not be loaded: the control then * names the area rather than the person, and still offers every destination. */ readonly account: HostAccount | "unavailable"; readonly onSignOut: () => void; /** Called after choosing a destination, so a drawer around the menu can close. */ readonly onNavigate?: () => void; } type AccountAction = | { readonly kind: "link"; readonly id: string; readonly label: string; readonly icon: ShellIconName; readonly testId: string; readonly to: DashboardDestination; } | { readonly kind: "button"; readonly id: string; readonly label: string; readonly icon: ShellIconName; readonly testId: string; readonly run: () => void; }; /** * The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5). * * A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the * trigger says it opens a menu and whether it is open; opening moves focus to the first item (or * the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and * returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it * went. * * It shows **who is signed in** — their name and email beside their initials (ADR 0062) — and * offers only destinations that work: Account (`/account`), Appearance (`/account/appearance`), and * Sign out. Account and Appearance are real links, so they open in a new tab like any other. When * the account cannot be loaded, the control names the area ("Your account") rather than the person. */ export function AccountMenu(props: AccountMenuProps): ReactElement { const [open, setOpen] = useState(false); const [initialFocus, setInitialFocus] = useState<"first" | "last">("first"); const wrapperRef = useRef(null); const triggerRef = useRef(null); const itemRefs = useRef<(HTMLElement | null)[]>([]); const menuId = useId(); const triggerId = useId(); const { collapsed } = props.presentation; const account = props.account === "unavailable" ? undefined : props.account; const name = account ? accountDisplayName(account) : "Your account"; const { tooltip, triggerProps } = useNavTooltip(name, collapsed && !open); const close = useCallback((returnFocus: boolean) => { setOpen(false); if (returnFocus) { triggerRef.current?.focus(); } }, []); const actions: readonly AccountAction[] = [ { icon: "account", id: "account", kind: "link", label: "Account", testId: "host-account-link", to: { kind: "account" }, }, { icon: "appearance", id: "appearance", kind: "link", label: "Appearance", testId: "host-appearance-link", to: { kind: "account-section", section: "appearance" }, }, { icon: "signOut", id: "sign-out", kind: "button", label: "Sign out", run: () => { setOpen(false); props.onSignOut(); }, testId: "host-logout", }, ]; const openMenu = (focus: "first" | "last"): void => { setInitialFocus(focus); setOpen(true); }; useEffect(() => { if (!open) { return; } const items = itemRefs.current.filter((item): item is HTMLElement => item !== null); (initialFocus === "last" ? items.at(-1) : items[0])?.focus(); }, [initialFocus, open]); // A press anywhere outside the menu and its trigger dismisses it, leaving focus alone. useEffect(() => { const owner = wrapperRef.current?.ownerDocument; if (!open || !owner) { return; } const dismiss = (event: Event): void => { if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) { setOpen(false); } }; owner.addEventListener("pointerdown", dismiss); return () => owner.removeEventListener("pointerdown", dismiss); }, [open]); const onMenuKeyDown = (event: KeyboardEvent): void => { const items = itemRefs.current.filter((item): item is HTMLElement => item !== null); const index = items.indexOf(event.target as HTMLElement); const focusAt = (next: number): void => { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); }; if (event.key === "Escape") { // Only the menu closes: a drawer around it stays open. event.preventDefault(); event.stopPropagation(); close(true); } else if (event.key === "ArrowDown") { focusAt(index + 1); } else if (event.key === "ArrowUp") { focusAt(index - 1); } else if (event.key === "Home") { focusAt(0); } else if (event.key === "End") { focusAt(items.length - 1); } else if (event.key === "Tab") { setOpen(false); } }; return (
{open ? (
{account ? (
{accountDisplayName(account)} {account.name.trim().length > 0 ? ( {account.email} ) : undefined}
) : undefined}
) : undefined} {tooltip}
); }