Skip to content

Commit e5db99d

Browse files
feat(components): support submenu items in main navigation (#645)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: SebastianSchuetze <[email protected]> Co-authored-by: Sebastian Schütze <[email protected]>
1 parent da3e059 commit e5db99d

File tree

12 files changed

+762
-32
lines changed

12 files changed

+762
-32
lines changed

src/app/(frontend)/layout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { draftMode } from 'next/headers'
1313
import './globals.css'
1414
import { getServerSideURL } from '@/utilities/getURL'
1515
import { getCachedGlobal } from '@/utilities/getGlobals'
16-
import { normalizeNavItems } from '@/utilities/normalizeNavItems'
16+
import { normalizeNavItems, normalizeHeaderNavItems } from '@/utilities/normalizeNavItems'
1717
import type { Footer as FooterType, Header as HeaderType } from '@/payload-types'
1818

1919
export default async function RootLayout({ children }: { children: React.ReactNode }) {
@@ -22,7 +22,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
2222
const headerData: HeaderType = await getCachedGlobal('header', 1)()
2323

2424
const footerNavItems = normalizeNavItems(footerData)
25-
const headerNavItems = normalizeNavItems(headerData)
25+
const headerNavItemsForFooter = normalizeNavItems(headerData)
26+
const headerNavItems = normalizeHeaderNavItems(headerData)
2627

2728
return (
2829
<html lang="en" suppressHydrationWarning>
@@ -44,7 +45,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
4445

4546
{/* Footer: Full-width */}
4647
<div className="full-width">
47-
<Footer footerNavItems={footerNavItems} headerNavItems={headerNavItems} />
48+
<Footer footerNavItems={footerNavItems} headerNavItems={headerNavItemsForFooter} />
4849
</div>
4950
</Providers>
5051
</body>

src/components/templates/Header/Component.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ import React from 'react'
55
import { Logo } from '@/components/molecules/Logo/Logo'
66
import { HeaderNav } from './Nav'
77
import { Container } from '@/components/molecules/Container'
8-
import type { UiLinkProps } from '@/components/molecules/Link'
8+
import type { HeaderNavItem } from '@/utilities/normalizeNavItems'
99

1010
interface HeaderProps {
11-
navItems: UiLinkProps[]
11+
navItems: HeaderNavItem[]
1212
}
1313

1414
export const Header: React.FC<HeaderProps> = ({ navItems }) => {
1515
return (
16-
<header className="bg-white">
16+
<header className="relative bg-white">
1717
<Container className="flex items-center justify-between py-4">
1818
<Link href="/">
1919
<Logo loading="eager" priority="high" className="h-14" />
Lines changed: 251 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,259 @@
11
'use client'
22

3-
import React from 'react'
4-
import { UiLink, type UiLinkProps } from '@/components/molecules/Link'
3+
import React, { useCallback, useEffect, useRef, useState } from 'react'
4+
import Link from 'next/link'
5+
import { ChevronDown, Menu, X } from 'lucide-react'
6+
import { cn } from '@/utilities/ui'
7+
import { UiLink } from '@/components/molecules/Link'
8+
import type { HeaderNavItem } from '@/utilities/normalizeNavItems'
9+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/atoms/accordion'
510

6-
export const HeaderNav: React.FC<{ navItems: UiLinkProps[] }> = ({ navItems }) => {
7-
const items = navItems || []
11+
/* ------------------------------------------------------------------ */
12+
/* Desktop dropdown for a single nav item with subItems */
13+
/* ------------------------------------------------------------------ */
14+
15+
const DesktopDropdown: React.FC<{
16+
item: HeaderNavItem
17+
open: boolean
18+
onOpen: () => void
19+
onClose: () => void
20+
onCloseWithDelay: () => void
21+
}> = ({ item, open, onOpen, onClose, onCloseWithDelay }) => {
22+
const handleKeyDown = useCallback(
23+
(e: React.KeyboardEvent) => {
24+
if (e.key === 'Escape') {
25+
onClose()
26+
}
27+
},
28+
[onClose],
29+
)
30+
31+
const handleBlur = useCallback(
32+
(e: React.FocusEvent<HTMLDivElement>) => {
33+
const nextTarget = e.relatedTarget as Node | null
34+
if (!e.currentTarget.contains(nextTarget)) {
35+
onClose()
36+
}
37+
},
38+
[onClose],
39+
)
40+
41+
return (
42+
<div
43+
className="relative"
44+
onMouseEnter={onOpen}
45+
onMouseLeave={onCloseWithDelay}
46+
onFocus={onOpen}
47+
onBlur={handleBlur}
48+
onKeyDown={handleKeyDown}
49+
>
50+
<button
51+
type="button"
52+
className={cn(
53+
'flex items-center gap-1 rounded-sm px-1.5 py-1 font-bold text-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden',
54+
open && 'text-foreground',
55+
)}
56+
aria-expanded={open}
57+
onClick={() => (open ? onClose() : onOpen())}
58+
>
59+
{item.label}
60+
<ChevronDown
61+
className={cn('h-4 w-4 shrink-0 transition-transform duration-200', open && 'rotate-180')}
62+
aria-hidden
63+
/>
64+
</button>
65+
66+
{open && (
67+
<div className="absolute top-full left-0 z-50 mt-2 min-w-52 rounded-md border border-zinc-200 bg-white p-2 shadow-sm">
68+
<ul className="space-y-1">
69+
{item.subItems?.map((sub) => {
70+
const newTabProps = sub.newTab ? { rel: 'noopener noreferrer' as const, target: '_blank' as const } : {}
71+
return (
72+
<li key={sub.href}>
73+
<Link
74+
href={sub.href}
75+
className="block rounded-sm px-3 py-2 text-foreground transition-colors hover:bg-zinc-200/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
76+
onClick={onClose}
77+
{...newTabProps}
78+
>
79+
{sub.label}
80+
</Link>
81+
</li>
82+
)
83+
})}
84+
</ul>
85+
</div>
86+
)}
87+
</div>
88+
)
89+
}
90+
91+
/* ------------------------------------------------------------------ */
92+
/* Mobile menu (slide-down panel with accordion sub-items) */
93+
/* ------------------------------------------------------------------ */
94+
95+
const MobileMenu: React.FC<{
96+
navItems: HeaderNavItem[]
97+
open: boolean
98+
onClose: () => void
99+
}> = ({ navItems, open, onClose }) => {
100+
if (!open) return null
8101

9102
return (
10-
<nav className="flex flex-wrap items-center gap-4 md:gap-6">
11-
{items.map((link, i) => {
12-
return <UiLink key={i} {...link} className="font-bold text-foreground transition-colors hover:text-primary" />
13-
})}
103+
<nav
104+
className="absolute inset-x-0 top-full z-40 border-t border-border bg-zinc-50 shadow-md md:hidden"
105+
aria-label="Mobile navigation"
106+
>
107+
<div className="flex flex-col px-4 py-2">
108+
{navItems.map((item) => {
109+
if (item.subItems && item.subItems.length > 0) {
110+
return (
111+
<Accordion key={item.href} type="single" collapsible>
112+
<AccordionItem value={`mobile-${item.href}`} className="border-b-0">
113+
<AccordionTrigger className="py-2 text-base font-semibold text-foreground hover:text-foreground hover:no-underline">
114+
{item.label}
115+
</AccordionTrigger>
116+
<AccordionContent className="border-t-0 pt-0 pb-2">
117+
<div className="flex flex-col gap-1 pl-4">
118+
{item.subItems.map((sub) => {
119+
const newTabProps = sub.newTab
120+
? { rel: 'noopener noreferrer' as const, target: '_blank' as const }
121+
: {}
122+
return (
123+
<Link
124+
key={sub.href}
125+
href={sub.href}
126+
className="rounded-sm px-3 py-2 text-sm text-foreground transition-colors hover:bg-zinc-100 hover:text-foreground"
127+
onClick={onClose}
128+
{...newTabProps}
129+
>
130+
{sub.label}
131+
</Link>
132+
)
133+
})}
134+
</div>
135+
</AccordionContent>
136+
</AccordionItem>
137+
</Accordion>
138+
)
139+
}
140+
141+
const newTabProps = item.newTab ? { rel: 'noopener noreferrer' as const, target: '_blank' as const } : {}
142+
return (
143+
<Link
144+
key={item.href}
145+
href={item.href}
146+
onClick={onClose}
147+
className="block py-2 text-base font-semibold text-foreground transition-colors hover:text-foreground"
148+
{...newTabProps}
149+
>
150+
{item.label}
151+
</Link>
152+
)
153+
})}
154+
</div>
14155
</nav>
15156
)
16157
}
158+
159+
/* ------------------------------------------------------------------ */
160+
/* HeaderNav — main export */
161+
/* ------------------------------------------------------------------ */
162+
163+
export const HeaderNav: React.FC<{ navItems: HeaderNavItem[] }> = ({ navItems }) => {
164+
const items = navItems || []
165+
const desktopNavRef = useRef<HTMLElement>(null)
166+
const closeDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null)
167+
const [openIndex, setOpenIndex] = useState<number | null>(null)
168+
const [mobileOpen, setMobileOpen] = useState(false)
169+
170+
const clearCloseDelay = useCallback(() => {
171+
if (!closeDelayRef.current) return
172+
clearTimeout(closeDelayRef.current)
173+
closeDelayRef.current = null
174+
}, [])
175+
176+
const closeDropdown = useCallback(() => {
177+
clearCloseDelay()
178+
setOpenIndex(null)
179+
}, [clearCloseDelay])
180+
181+
const closeDropdownWithDelay = useCallback(() => {
182+
clearCloseDelay()
183+
closeDelayRef.current = setTimeout(() => {
184+
setOpenIndex(null)
185+
closeDelayRef.current = null
186+
}, 180)
187+
}, [clearCloseDelay])
188+
189+
const openDropdownAtIndex = useCallback(
190+
(index: number) => {
191+
clearCloseDelay()
192+
setOpenIndex(index)
193+
},
194+
[clearCloseDelay],
195+
)
196+
197+
useEffect(() => clearCloseDelay, [clearCloseDelay])
198+
199+
// Close desktop dropdown on outside click
200+
useEffect(() => {
201+
if (openIndex === null) return
202+
const handleClickOutside = (event: MouseEvent) => {
203+
const target = event.target
204+
if (!(target instanceof Node)) return
205+
if (desktopNavRef.current?.contains(target)) return
206+
closeDropdown()
207+
}
208+
209+
document.addEventListener('click', handleClickOutside)
210+
return () => document.removeEventListener('click', handleClickOutside)
211+
}, [closeDropdown, openIndex])
212+
213+
return (
214+
<>
215+
{/* Desktop nav */}
216+
<nav ref={desktopNavRef} className="hidden items-center gap-4 md:flex md:gap-6" aria-label="Main navigation">
217+
{items.map((item, i) => {
218+
if (item.subItems && item.subItems.length > 0) {
219+
return (
220+
<DesktopDropdown
221+
key={item.href}
222+
item={item}
223+
open={openIndex === i}
224+
onOpen={() => openDropdownAtIndex(i)}
225+
onClose={closeDropdown}
226+
onCloseWithDelay={closeDropdownWithDelay}
227+
/>
228+
)
229+
}
230+
231+
return (
232+
<UiLink
233+
key={item.href}
234+
href={item.href}
235+
label={item.label}
236+
newTab={item.newTab}
237+
appearance="inline"
238+
className="rounded-sm px-1.5 py-1 font-bold text-foreground transition-colors hover:text-foreground"
239+
/>
240+
)
241+
})}
242+
</nav>
243+
244+
{/* Mobile hamburger toggle */}
245+
<button
246+
type="button"
247+
className="inline-flex items-center justify-center rounded-md p-2 text-foreground transition-colors hover:bg-zinc-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden md:hidden"
248+
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
249+
aria-expanded={mobileOpen}
250+
onClick={() => setMobileOpen((prev) => !prev)}
251+
>
252+
{mobileOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
253+
</button>
254+
255+
{/* Mobile panel */}
256+
<MobileMenu navItems={items} open={mobileOpen} onClose={() => setMobileOpen(false)} />
257+
</>
258+
)
259+
}

src/globals/Header/config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ export const Header: GlobalConfig = {
1616
link({
1717
appearances: false,
1818
}),
19+
{
20+
name: 'subItems',
21+
type: 'array',
22+
fields: [
23+
link({
24+
appearances: false,
25+
}),
26+
],
27+
maxRows: 8,
28+
admin: {
29+
description: 'Optional submenu links displayed under this nav item.',
30+
initCollapsed: true,
31+
},
32+
},
1933
],
2034
maxRows: 6,
2135
admin: {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
2+
3+
export async function up({ db }: MigrateUpArgs): Promise<void> {
4+
await db.execute(sql`
5+
DO $$
6+
BEGIN
7+
CREATE TYPE "public"."enum_header_nav_items_sub_items_link_type" AS ENUM('reference', 'custom');
8+
EXCEPTION
9+
WHEN duplicate_object THEN null;
10+
END $$;
11+
12+
CREATE TABLE IF NOT EXISTS "header_nav_items_sub_items" (
13+
"_order" integer NOT NULL,
14+
"_parent_id" varchar NOT NULL,
15+
"id" varchar PRIMARY KEY NOT NULL,
16+
"link_type" "enum_header_nav_items_sub_items_link_type" DEFAULT 'reference',
17+
"link_new_tab" boolean,
18+
"link_url" varchar,
19+
"link_label" varchar NOT NULL
20+
);
21+
22+
CREATE INDEX IF NOT EXISTS "header_nav_items_sub_items_order_idx" ON "header_nav_items_sub_items" USING btree ("_order");
23+
CREATE INDEX IF NOT EXISTS "header_nav_items_sub_items_parent_id_idx" ON "header_nav_items_sub_items" USING btree ("_parent_id");
24+
25+
DO $$
26+
BEGIN
27+
IF NOT EXISTS (
28+
SELECT 1 FROM pg_constraint WHERE conname = 'header_nav_items_sub_items_parent_id_fk'
29+
) THEN
30+
ALTER TABLE "header_nav_items_sub_items"
31+
ADD CONSTRAINT "header_nav_items_sub_items_parent_id_fk"
32+
FOREIGN KEY ("_parent_id") REFERENCES "public"."header_nav_items"("id") ON DELETE cascade ON UPDATE no action;
33+
END IF;
34+
END $$;
35+
`)
36+
}
37+
38+
export async function down({ db }: MigrateDownArgs): Promise<void> {
39+
await db.execute(sql`
40+
DROP TABLE IF EXISTS "header_nav_items_sub_items" CASCADE;
41+
DROP TYPE IF EXISTS "public"."enum_header_nav_items_sub_items_link_type";
42+
`)
43+
}

src/migrations/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as migration_20260120_202321_payload_mcp from './20260120_202321_payloa
33
import * as migration_20260121_075315_add_imports_collection from './20260121_075315_add_imports_collection'
44
import * as migration_20260123_101500_user_profile_media_drop_alt_caption from './20260123_101500_user_profile_media_drop_alt_caption'
55
import * as migration_20260126_144212_import_plugin from './20260126_144212_import_plugin'
6+
import * as migration_20260206_201500_header_nav_sub_items from './20260206_201500_header_nav_sub_items'
67
import * as migration_20260206_103034_compliance_blog_schema from './20260206_103034_compliance_blog_schema'
78
import * as migration_20260206_201356_autho_basic_change from './20260206_201356_autho_basic_change'
89

@@ -32,6 +33,11 @@ export const migrations = [
3233
down: migration_20260126_144212_import_plugin.down,
3334
name: '20260126_144212_import_plugin',
3435
},
36+
{
37+
up: migration_20260206_201500_header_nav_sub_items.up,
38+
down: migration_20260206_201500_header_nav_sub_items.down,
39+
name: '20260206_201500_header_nav_sub_items',
40+
},
3541
{
3642
up: migration_20260206_103034_compliance_blog_schema.up,
3743
down: migration_20260206_103034_compliance_blog_schema.down,

0 commit comments

Comments
 (0)