Skip to content

Commit 2f3a7ac

Browse files
calliclesclaude
andauthored
Add dropdown menu component and refactor site header navigation (#50)
## Summary This PR introduces a new dropdown menu UI component and refactors the site header to use it for improved navigation and user account management on mobile and desktop views. ## Key Changes - **New Component**: Added `components/ui/dropdown-menu.tsx` - a fully-featured dropdown menu component built on Radix UI's dropdown menu primitive with support for: - Submenus with nested triggers and content - Radio groups for selection - Labels, separators, and grouped items - Smooth animations and proper positioning - Accessible keyboard navigation and focus management - **Site Header Refactor**: Updated `components/site-header-client.tsx` to: - Replace the static horizontal navigation bar with a dropdown menu for authenticated users - Move navigation items into a user profile dropdown menu - Display user avatar, name, and GitHub username in the dropdown header - Consolidate sign-out action into the dropdown menu - Improve mobile/responsive UX by reducing header clutter - Add visual feedback with chevron icon on the trigger button - **Dependencies**: Added `@radix-ui/react-dropdown-menu` for the underlying dropdown functionality ## Implementation Details - The dropdown menu component follows the shadcn/ui pattern with proper TypeScript support and ref forwarding - Navigation items are now contextually displayed in the user dropdown with active state styling - The dropdown trigger is a custom button that displays user info and responds to hover states - All dropdown content is properly positioned and animated with Radix UI's built-in animation support https://claude.ai/code/session_01RchFL7TGNgpJnqoL7BAEav <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk UI-only refactor: changes header navigation presentation and adds a new dropdown component plus a dependency, with no changes to auth/data flows beyond relocating the existing sign-out action. > > **Overview** > Refactors `SiteHeaderClient` so authenticated users access `Admin`/`Contributor`/`Compare`/`Docs` and `Sign out` from a new profile dropdown (showing avatar/name/GitHub username) instead of a persistent desktop nav bar. > > Adds a reusable `components/ui/dropdown-menu.tsx` wrapper around Radix DropdownMenu primitives (content, items, labels, separators, submenus/radio groups) and introduces `@radix-ui/react-dropdown-menu` to dependencies (lockfile updated accordingly). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e0e10df. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6b276d commit 2f3a7ac

4 files changed

Lines changed: 605 additions & 41 deletions

File tree

components/site-header-client.tsx

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import { cn } from "@/lib/utils"
88
import { signOutAction } from "@/app/actions/auth"
99
import { BrandLockup } from "@/components/brand-logo"
1010
import { Button } from "@/components/ui/button"
11-
import { Github, LogOut, Menu, X } from "lucide-react"
11+
import {
12+
DropdownMenu,
13+
DropdownMenuContent,
14+
DropdownMenuItem,
15+
DropdownMenuLabel,
16+
DropdownMenuSeparator,
17+
DropdownMenuTrigger,
18+
} from "@/components/ui/dropdown-menu"
19+
import { ChevronDown, Github, LogOut, Menu, X } from "lucide-react"
1220
import type { SessionUserDto } from "@/lib/session-user"
1321
import { SessionMonitor } from "@/components/session-monitor"
1422

@@ -37,49 +45,56 @@ export function SiteHeaderClient({ user }: SiteHeaderClientProps) {
3745
<BrandLockup subtitleClassName="hidden sm:block text-[11px] text-muted-foreground" />
3846
</Link>
3947

40-
<nav className="hidden items-center gap-1 md:flex" aria-label="Primary navigation">
41-
{navItems.map((item) => (
42-
<Link
43-
key={item.href}
44-
href={item.href}
45-
className={cn(
46-
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
47-
pathname.startsWith(item.href)
48-
? "bg-secondary text-foreground"
49-
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
50-
)}
51-
>
52-
{item.label}
53-
</Link>
54-
))}
55-
</nav>
56-
5748
<div className="hidden items-center gap-3 md:flex">
5849
{isSignedIn && user ? (
59-
<div className="flex items-center gap-3">
60-
<div className="flex items-center gap-2">
61-
<Image
62-
src={user.avatarUrl || "/placeholder.svg"}
63-
alt={user.name}
64-
width={28}
65-
height={28}
66-
className="h-7 w-7 rounded-full"
67-
sizes="28px"
68-
/>
69-
<span className="text-sm font-medium text-foreground">{user.name}</span>
70-
</div>
71-
<form action={signOutAction}>
72-
<Button
73-
variant="ghost"
74-
size="sm"
75-
className="gap-1.5 text-muted-foreground hover:text-foreground"
76-
type="submit"
50+
<DropdownMenu>
51+
<DropdownMenuTrigger asChild>
52+
<button
53+
type="button"
54+
className="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary"
7755
>
78-
<LogOut className="h-3.5 w-3.5" />
79-
<span className="sr-only">Sign out</span>
80-
</Button>
81-
</form>
82-
</div>
56+
<Image
57+
src={user.avatarUrl || "/placeholder.svg"}
58+
alt={user.name}
59+
width={28}
60+
height={28}
61+
className="h-7 w-7 rounded-full"
62+
sizes="28px"
63+
/>
64+
<span className="text-sm font-medium text-foreground">{user.name}</span>
65+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
66+
</button>
67+
</DropdownMenuTrigger>
68+
<DropdownMenuContent align="end" className="w-48">
69+
<DropdownMenuLabel className="font-normal">
70+
<p className="text-sm font-medium">{user.name}</p>
71+
<p className="text-xs text-muted-foreground">@{user.githubUsername}</p>
72+
</DropdownMenuLabel>
73+
<DropdownMenuSeparator />
74+
{navItems.map((item) => (
75+
<DropdownMenuItem key={item.href} asChild>
76+
<Link
77+
href={item.href}
78+
className={cn(
79+
"w-full cursor-pointer",
80+
pathname.startsWith(item.href) && "bg-secondary"
81+
)}
82+
>
83+
{item.label}
84+
</Link>
85+
</DropdownMenuItem>
86+
))}
87+
<DropdownMenuSeparator />
88+
<DropdownMenuItem asChild>
89+
<form action={signOutAction} className="w-full">
90+
<button type="submit" className="flex w-full items-center gap-2 text-sm">
91+
<LogOut className="h-3.5 w-3.5" />
92+
Sign out
93+
</button>
94+
</form>
95+
</DropdownMenuItem>
96+
</DropdownMenuContent>
97+
</DropdownMenu>
8398
) : (
8499
<Link href="/auth/signin">
85100
<Button variant="outline" size="sm" className="gap-2 bg-transparent">

components/ui/dropdown-menu.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
const DropdownMenu = DropdownMenuPrimitive.Root
9+
10+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
11+
12+
const DropdownMenuGroup = DropdownMenuPrimitive.Group
13+
14+
const DropdownMenuSub = DropdownMenuPrimitive.Sub
15+
16+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
17+
18+
const DropdownMenuSubTrigger = React.forwardRef<
19+
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
20+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
21+
inset?: boolean
22+
}
23+
>(({ className, inset, children, ...props }, ref) => (
24+
<DropdownMenuPrimitive.SubTrigger
25+
ref={ref}
26+
className={cn(
27+
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
28+
inset && "pl-8",
29+
className
30+
)}
31+
{...props}
32+
>
33+
{children}
34+
</DropdownMenuPrimitive.SubTrigger>
35+
))
36+
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
37+
38+
const DropdownMenuSubContent = React.forwardRef<
39+
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
40+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
41+
>(({ className, ...props }, ref) => (
42+
<DropdownMenuPrimitive.SubContent
43+
ref={ref}
44+
className={cn(
45+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
46+
className
47+
)}
48+
{...props}
49+
/>
50+
))
51+
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
52+
53+
const DropdownMenuContent = React.forwardRef<
54+
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
55+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
56+
>(({ className, sideOffset = 4, ...props }, ref) => (
57+
<DropdownMenuPrimitive.Portal>
58+
<DropdownMenuPrimitive.Content
59+
ref={ref}
60+
sideOffset={sideOffset}
61+
className={cn(
62+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
63+
className
64+
)}
65+
{...props}
66+
/>
67+
</DropdownMenuPrimitive.Portal>
68+
))
69+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
70+
71+
const DropdownMenuItem = React.forwardRef<
72+
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
73+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
74+
inset?: boolean
75+
}
76+
>(({ className, inset, ...props }, ref) => (
77+
<DropdownMenuPrimitive.Item
78+
ref={ref}
79+
className={cn(
80+
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
81+
inset && "pl-8",
82+
className
83+
)}
84+
{...props}
85+
/>
86+
))
87+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
88+
89+
const DropdownMenuLabel = React.forwardRef<
90+
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
91+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
92+
inset?: boolean
93+
}
94+
>(({ className, inset, ...props }, ref) => (
95+
<DropdownMenuPrimitive.Label
96+
ref={ref}
97+
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
98+
{...props}
99+
/>
100+
))
101+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
102+
103+
const DropdownMenuSeparator = React.forwardRef<
104+
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
105+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
106+
>(({ className, ...props }, ref) => (
107+
<DropdownMenuPrimitive.Separator
108+
ref={ref}
109+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
110+
{...props}
111+
/>
112+
))
113+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
114+
115+
export {
116+
DropdownMenu,
117+
DropdownMenuTrigger,
118+
DropdownMenuContent,
119+
DropdownMenuItem,
120+
DropdownMenuLabel,
121+
DropdownMenuSeparator,
122+
DropdownMenuGroup,
123+
DropdownMenuSub,
124+
DropdownMenuSubContent,
125+
DropdownMenuSubTrigger,
126+
DropdownMenuRadioGroup,
127+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@neondatabase/serverless": "^0.10.0",
2828
"@octokit/auth-app": "^8.1.2",
2929
"@octokit/rest": "^22.0.1",
30+
"@radix-ui/react-dropdown-menu": "^2.1.16",
3031
"@radix-ui/react-slot": "1.1.1",
3132
"@vercel/analytics": "^2.0.1",
3233
"@vercel/toolbar": "^0.2.2",

0 commit comments

Comments
 (0)