// frontend/components/UserMenu.jsx import React, { useEffect, useRef, useState, useCallback } from "react"; /** * UserMenu — account dropdown attached to the profile avatar in the * bottom-left of the sidebar. Follows the Claude Code / ChatGPT pattern: * click avatar → popover with Settings, About, Logout. * * Best practices applied: * - Click outside to close (mousedown listener on document) * - Escape key closes * - ARIA: role="menu" + aria-haspopup + aria-expanded on trigger * - Keyboard navigation (Tab / Shift+Tab cycles items, Enter activates) * - Position: absolute popover anchored to trigger, opens upward * - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border * - Respects sidebarCollapsed: when collapsed, only avatar is shown * - Animation: subtle fade+translate for polish */ export default function UserMenu({ userInfo, sidebarCollapsed = false, onOpenSettings, onOpenAbout, onLogout, }) { const [open, setOpen] = useState(false); const [fixedPos, setFixedPos] = useState(null); const containerRef = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); // When the sidebar is collapsed, the parent .sidebar has overflow-x:hidden // which clips an absolutely-positioned popover. Escape the clip by using // position:fixed with coordinates measured from the trigger's bounding // rect. Recompute on open, window resize, and scroll. useEffect(() => { if (!open || !sidebarCollapsed) { setFixedPos(null); return; } const compute = () => { const el = triggerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); setFixedPos({ left: Math.round(rect.right + 8), bottom: Math.round(window.innerHeight - rect.bottom), }); }; compute(); window.addEventListener("resize", compute); window.addEventListener("scroll", compute, true); return () => { window.removeEventListener("resize", compute); window.removeEventListener("scroll", compute, true); }; }, [open, sidebarCollapsed]); // Close on click outside useEffect(() => { if (!open) return; const handleDocMouseDown = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setOpen(false); } }; document.addEventListener("mousedown", handleDocMouseDown); return () => document.removeEventListener("mousedown", handleDocMouseDown); }, [open]); // Close on Escape useEffect(() => { if (!open) return; const handleKey = (e) => { if (e.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [open]); // Focus the first menu item when opened useEffect(() => { if (open && menuRef.current) { const firstItem = menuRef.current.querySelector('[role="menuitem"]'); firstItem?.focus(); } }, [open]); const handleItemClick = useCallback((action) => { setOpen(false); // Defer to next tick so the dropdown close animation doesn't jitter // against the modal open animation. window.setTimeout(() => action?.(), 0); }, []); if (!userInfo) return null; const displayName = userInfo.name || userInfo.login; const login = userInfo.login || ""; return (
{/* Trigger: avatar + optional name */} {/* Dropdown popover */} {open && (
{/* Header: show full email/username for context */}
Signed in as
{displayName}
} label="Settings" onClick={() => handleItemClick(onOpenSettings)} /> } label="About GitPilot" onClick={() => handleItemClick(onOpenAbout)} />
} label="Log out" onClick={() => handleItemClick(onLogout)} danger />
)} {/* Scoped keyframe animation */}
); } // ── Menu item primitive ──────────────────────────────────────────── function MenuItem({ icon, label, onClick, danger = false }) { const [hover, setHover] = useState(false); const color = danger ? "#f87171" : "#EDEDED"; return ( ); } // ── Inline icons (no extra asset loads) ──────────────────────────── function SettingsIcon() { return ( ); } function InfoIcon() { return ( ); } function LogoutIcon() { return ( ); }