feat(web): convert navbar profile pill into a dropdown menu
The profile pill in the top nav was a static `<div>` showing the
avatar + name + role with no way to reach the dashboard, profile
or logout from the desktop layout — testers reported "không có
dropdown dashboard" after login.
Changes to `components/design-system/navbar.tsx`:
* The pill is now a `<button>` that toggles an absolutely-positioned
menu (right-aligned, `z-popover`, elevation-3 shadow). A chevron
rotates to indicate state.
* Outside-click and Escape close the menu (effect listens only while
the menu is open).
* The menu has:
- A header card with the bigger avatar + full name + email/phone.
- Dashboard / Admin entry (icon depends on role) — replaces the
separate green dashboard button that used to live to the right
of the pill.
- Profile entry → `profileHref`.
- Divider, then a destructive "Đăng xuất" button calling `onLogout`.
* Each link uses the existing `renderLink` slot so framework-specific
Link components (Next.js / next-intl) keep working, and they close
the menu on click.
Tests updated: the dashboard / admin assertions now click the
trigger to open the menu, then look for `role="menuitem"` entries.
All 16 navbar tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,24 +99,31 @@ describe('Navbar', () => {
|
|||||||
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders dashboard button for authenticated user', () => {
|
it('renders dashboard menu item for authenticated user (after opening dropdown)', () => {
|
||||||
render(
|
render(
|
||||||
<Navbar
|
<Navbar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
|
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Quản lý')).toBeInTheDocument();
|
// The pill is the dropdown trigger; click it to reveal the menu.
|
||||||
|
const trigger = screen.getByRole('button', { name: /Nguyễn Văn A/ });
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getByRole('menuitem', { name: /Quản lý/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders admin label for ADMIN role', () => {
|
it('renders admin label as a role badge AND in the dropdown for ADMIN role', () => {
|
||||||
render(
|
render(
|
||||||
<Navbar
|
<Navbar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
user={{ fullName: 'Admin User', role: 'ADMIN' }}
|
user={{ fullName: 'Admin User', role: 'ADMIN' }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Quản trị')).toBeInTheDocument();
|
// Role badge in the trigger pill is always visible.
|
||||||
|
expect(screen.getByText('Quản trị viên')).toBeInTheDocument();
|
||||||
|
// After opening, the ADMIN-specific menu item shows.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Admin User/ }));
|
||||||
|
expect(screen.getByRole('menuitem', { name: /Quản trị/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows moon icon in light theme', () => {
|
it('shows moon icon in light theme', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -118,14 +119,36 @@ export function Navbar({
|
|||||||
renderLink,
|
renderLink,
|
||||||
}: NavbarProps) {
|
}: NavbarProps) {
|
||||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
|
||||||
|
const userMenuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const close = () => setMobileOpen(false);
|
const close = () => setMobileOpen(false);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
close();
|
close();
|
||||||
|
setUserMenuOpen(false);
|
||||||
await onLogout();
|
await onLogout();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close the desktop user dropdown on outside click + Escape.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!userMenuOpen) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
role="banner"
|
role="banner"
|
||||||
@@ -196,47 +219,123 @@ export function Navbar({
|
|||||||
<>
|
<>
|
||||||
<div className="hidden sm:block">{notifications}</div>
|
<div className="hidden sm:block">{notifications}</div>
|
||||||
|
|
||||||
{/* User pill */}
|
{/* User dropdown — pill is the trigger, menu opens on click */}
|
||||||
<div className="hidden items-center gap-2 rounded-full border border-border bg-background-elevated px-2 py-1 sm:flex">
|
<div ref={userMenuRef} className="relative hidden sm:block">
|
||||||
{user.avatarUrl ? (
|
<button
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
type="button"
|
||||||
<img
|
onClick={() => setUserMenuOpen((v) => !v)}
|
||||||
src={user.avatarUrl}
|
aria-haspopup="menu"
|
||||||
alt=""
|
aria-expanded={userMenuOpen}
|
||||||
className="h-6 w-6 rounded-full border object-cover"
|
className="flex items-center gap-2 rounded-full border border-border bg-background-elevated px-2 py-1 text-left transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-6 w-6 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary">
|
||||||
|
{getInitials(user.fullName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[10rem] truncate text-sm text-foreground">
|
||||||
|
{user.fullName}
|
||||||
|
</span>
|
||||||
|
{ROLE_LABELS[user.role] && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
{ROLE_LABELS[user.role]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||||
|
userMenuOpen && 'rotate-180',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : (
|
</button>
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary">
|
|
||||||
{getInitials(user.fullName)}
|
{userMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={user.fullName}
|
||||||
|
className="absolute right-0 top-full z-popover mt-2 w-56 overflow-hidden rounded-lg border border-border bg-background-elevated shadow-elevation-3"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-background-surface px-3 py-2.5">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||||
|
{getInitials(user.fullName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{user.fullName}
|
||||||
|
</span>
|
||||||
|
{(user.email || user.phone) && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{user.email ?? user.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
{renderLink({
|
||||||
|
href: dashboardHref,
|
||||||
|
onClick: () => setUserMenuOpen(false),
|
||||||
|
children: (
|
||||||
|
<span
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{user.role === 'ADMIN' ? (
|
||||||
|
<Shield className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<LayoutDashboard className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{user.role === 'ADMIN' ? labels.admin : labels.dashboard}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
{renderLink({
|
||||||
|
href: profileHref,
|
||||||
|
onClick: () => setUserMenuOpen(false),
|
||||||
|
children: (
|
||||||
|
<span
|
||||||
|
role="menuitem"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<UserIcon className="h-4 w-4" aria-hidden />
|
||||||
|
{labels.profile}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
void handleLogout();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-left text-sm text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" aria-hidden />
|
||||||
|
{labels.logout}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[10rem] truncate text-sm text-foreground">
|
|
||||||
{user.fullName}
|
|
||||||
</span>
|
|
||||||
{ROLE_LABELS[user.role] && (
|
|
||||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
||||||
{ROLE_LABELS[user.role]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderLink({
|
|
||||||
href: dashboardHref,
|
|
||||||
className: 'hidden sm:inline-flex',
|
|
||||||
children: (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
|
|
||||||
>
|
|
||||||
{user.role === 'ADMIN' ? (
|
|
||||||
<Shield className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<LayoutDashboard className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
)}
|
|
||||||
{user.role === 'ADMIN' ? labels.admin : labels.dashboard}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user