File size: 5,382 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | /**
* AccountMenu β enterprise-grade account popover anchored to the sidebar avatar.
*
* Structure (Claude / Slack / GitHub pattern):
* [Avatar] Display Name
* email / username
* βββββββββββββββββββββββββ
* Personalization
* Settings
* βββββββββββββββββββββββββ
* About
* βββββββββββββββββββββββββ
* Log out
*
* UX rules:
* - Opens upward from the avatar (bottom-left anchor)
* - Closes on outside click or Esc
* - Keyboard-navigable (arrow keys, Enter)
* - Log out: one click, no confirmation, instant redirect
* - Identity at top, exit at bottom
*/
import React, { useEffect, useRef, useCallback } from 'react';
import { Settings, LogOut, Palette, Info } from 'lucide-react';
import UserAvatar from './UserAvatar';
export default function AccountMenu({ user, onClose, onOpenSettings, onOpenProfile, onOpenAbout, onLogout, }) {
const menuRef = useRef(null);
const itemsRef = useRef([]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e) {
if (menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
}
// Delay to avoid the same click that opened the menu from closing it
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Keyboard navigation
const handleKeyDown = useCallback((e) => {
const items = itemsRef.current.filter(Boolean);
const currentIndex = items.indexOf(document.activeElement);
switch (e.key) {
case 'Escape':
e.preventDefault();
onClose();
break;
case 'ArrowDown':
e.preventDefault();
if (currentIndex < items.length - 1) {
items[currentIndex + 1]?.focus();
}
else {
items[0]?.focus();
}
break;
case 'ArrowUp':
e.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1]?.focus();
}
else {
items[items.length - 1]?.focus();
}
break;
case 'Tab':
e.preventDefault();
onClose();
break;
}
}, [onClose]);
// Focus first item on mount
useEffect(() => {
const first = itemsRef.current.find(Boolean);
first?.focus();
}, []);
const setItemRef = (index) => (el) => {
if (el)
itemsRef.current[index] = el;
};
const menuItemClass = [
'w-full text-left px-3 py-2 text-[13px] rounded-lg',
'flex items-center gap-2.5',
'text-white/80 hover:bg-white/8 hover:text-white',
'focus:bg-white/8 focus:text-white focus:outline-none',
'transition-colors cursor-pointer',
].join(' ');
return (<div ref={menuRef} role="menu" aria-label="Account menu" onKeyDown={handleKeyDown} className="absolute bottom-[72px] left-3 w-[260px] bg-[#0f0f1a] border border-white/10 rounded-2xl shadow-2xl z-50 ring-1 ring-white/5 overflow-hidden" style={{ animation: 'accountMenuIn 150ms ease-out' }}>
{/* Identity header */}
<div className="px-4 py-3.5 flex items-center gap-3 border-b border-white/5">
<UserAvatar displayName={user.display_name || user.username} avatarUrl={user.avatar_url} size={36}/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white truncate">
{user.display_name || user.username}
</div>
<div className="text-[11px] text-white/40 truncate">
{user.email || `@${user.username}`}
</div>
</div>
</div>
{/* Main actions */}
<div className="px-2 py-1.5">
<button ref={setItemRef(0)} role="menuitem" className={menuItemClass} onClick={() => { onClose(); onOpenProfile(); }}>
<Palette size={15} className="text-white/40 shrink-0"/>
Personalization
</button>
<button ref={setItemRef(1)} role="menuitem" className={menuItemClass} onClick={() => { onClose(); onOpenSettings(); }}>
<Settings size={15} className="text-white/40 shrink-0"/>
Settings
</button>
</div>
{/* About */}
<div className="border-t border-white/5 px-2 py-1.5">
<button ref={setItemRef(2)} role="menuitem" className={menuItemClass} onClick={() => { onClose(); onOpenAbout(); }}>
<Info size={15} className="text-white/40 shrink-0"/>
About
</button>
</div>
{/* Log out β always last, visually separated */}
<div className="border-t border-white/5 px-2 py-1.5">
<button ref={setItemRef(3)} role="menuitem" className={menuItemClass} onClick={onLogout}>
<LogOut size={15} className="text-white/40 shrink-0"/>
Log out
</button>
</div>
</div>);
}
|