Skip to content

Commit bedd096

Browse files
committed
feat(navigation): add standardized header dropdown menu component
1 parent 6737afd commit bedd096

5 files changed

Lines changed: 180 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ src/
2525
RootLayout.astro # Root HTML layout mit Header, Footer, Font, SEO-Meta
2626
Header.astro # Responsive Header mit Desktop-Nav und mobilem Menü
2727
Footer.astro # Footer mit Copyright und Links
28+
DesktopNav.tsx # Desktop-Navigation mit Hover-Dropdowns (shadcn NavigationMenu)
2829
MobileNav.tsx # Mobile Navigation (shadcn Sheet, von rechts)
2930
ContactForm.tsx # Kontaktformular (React, valibot, Honeypot, Time-Based)
3031
ui/ # shadcn/ui components
@@ -88,7 +89,10 @@ public/
8889
- `config.name`: Seitenname, wird in Header, Footer, Mobile Nav und Seitentiteln verwendet
8990
- `config.tagline`: Tagline, wird auf der Startseite angezeigt
9091
- `config.site`: URL der Seite — **WICHTIG: muss auch in `astro.config.mjs` (Zeile 10) identisch gepflegt werden** (Astro kann `config.ts` nicht importieren wegen Vite-Init). `allowedDomains` in `astro.config.mjs` leitet sich automatisch aus der dortigen URL ab.
91-
- `config.navigation.header` / `config.navigation.footer`: Nav-Links als `{ label, href }[]`
92+
- `config.navigation.header` / `config.navigation.footer`: Nav-Links vom Typ `NavLink[]` (aus `config.ts`)
93+
- Einfacher Link: `{ label, href }`
94+
- Dropdown (Hover-Menü mit Unterlinks): `{ label, items: [{ label, href }, …] }``href` am Dropdown-Eintrag ist optional (z.B. Übersichtsseite)
95+
- Hat ein Header-Eintrag `items`, rendert `Header.astro` automatisch die interaktive `DesktopNav`-Island (`client:load`). Ohne Dropdown bleibt die Desktop-Nav statisches HTML ohne Client-JS. Das mobile Menü (`MobileNav`) zeigt Unterlinks als eingerückte Gruppe.
9296
- `config.smtp`: SMTP-Zugangsdaten für Kontaktformular. Default-Host: `mail.agenturserver.de`. STARTTLS: Port 25/587 (`secure: false`), SSL: Port 465 (`secure: true`). Default ist STARTTLS auf Port 587.
9397
- `config.erecht24.apiKey`: API-Key für eRecht24 Impressum/Datenschutz — ohne Key werden Platzhalter ausgeliefert
9498

src/components/DesktopNav.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"use client";
2+
3+
import {
4+
NavigationMenu,
5+
NavigationMenuContent,
6+
NavigationMenuItem,
7+
NavigationMenuLink,
8+
NavigationMenuList,
9+
NavigationMenuTrigger,
10+
} from "@/components/ui/navigation-menu";
11+
import type { NavLink } from "@/config";
12+
import { cn } from "@/lib/utils";
13+
14+
function isActive(href: string | undefined, currentPath: string) {
15+
if (!href) return false;
16+
return currentPath === href || currentPath === href + "/";
17+
}
18+
19+
export function DesktopNav({
20+
links,
21+
currentPath,
22+
}: {
23+
links: NavLink[];
24+
currentPath: string;
25+
}) {
26+
if (links.length === 0) return null;
27+
28+
return (
29+
<NavigationMenu viewport={false} className="hidden md:flex">
30+
<NavigationMenuList className="gap-6">
31+
{links.map((link) => {
32+
// Eintrag mit Unterlinks → Hover-Dropdown
33+
if (link.items && link.items.length > 0) {
34+
const active = link.items.some((item) =>
35+
isActive(item.href, currentPath),
36+
);
37+
return (
38+
<NavigationMenuItem key={link.label}>
39+
<NavigationMenuTrigger
40+
className={cn(
41+
"h-auto gap-1 bg-transparent px-0 py-0 text-sm font-normal hover:bg-transparent focus:bg-transparent data-open:bg-transparent data-popup-open:bg-transparent",
42+
active
43+
? "text-foreground font-medium"
44+
: "text-muted-foreground hover:text-foreground data-open:text-foreground",
45+
)}
46+
>
47+
{link.label}
48+
</NavigationMenuTrigger>
49+
<NavigationMenuContent>
50+
<ul className="grid w-48 gap-0.5 p-1.5">
51+
{link.items.map((item) => (
52+
<li key={item.href}>
53+
<NavigationMenuLink
54+
asChild
55+
active={isActive(item.href, currentPath)}
56+
>
57+
<a href={item.href}>{item.label}</a>
58+
</NavigationMenuLink>
59+
</li>
60+
))}
61+
</ul>
62+
</NavigationMenuContent>
63+
</NavigationMenuItem>
64+
);
65+
}
66+
67+
// Einfacher Link (kein Dropdown)
68+
const active = isActive(link.href, currentPath);
69+
return (
70+
<NavigationMenuItem key={link.href ?? link.label}>
71+
<a
72+
href={link.href}
73+
className={cn(
74+
"text-sm transition-colors",
75+
active
76+
? "text-foreground font-medium"
77+
: "text-muted-foreground hover:text-foreground",
78+
)}
79+
aria-current={active ? "page" : undefined}
80+
>
81+
{link.label}
82+
</a>
83+
</NavigationMenuItem>
84+
);
85+
})}
86+
</NavigationMenuList>
87+
</NavigationMenu>
88+
);
89+
}

src/components/Header.astro

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
---
22
import { config } from "@/config";
33
import { MobileNav } from "@/components/MobileNav";
4+
import { DesktopNav } from "@/components/DesktopNav";
45
import { Icon } from "astro-icon/components";
56
67
const { name, navigation } = config;
78
const links = navigation.header;
89
const currentPath = Astro.url.pathname;
10+
11+
const hasDropdown = links.some((link) => (link.items?.length ?? 0) > 0);
912
---
1013

1114
<header class="border-border border-b">
@@ -25,29 +28,33 @@ const currentPath = Astro.url.pathname;
2528
<div class="flex items-center gap-6">
2629
{/* Desktop */}
2730
{
28-
links.length > 0 && (
29-
<ul class="hidden items-center gap-6 md:flex">
30-
{links.map((link) => {
31-
const isActive =
32-
currentPath === link.href || currentPath === link.href + "/";
33-
return (
34-
<li>
35-
<a
36-
href={link.href}
37-
class:list={[
38-
"text-sm transition-colors",
39-
isActive
40-
? "text-foreground font-medium"
41-
: "text-muted-foreground hover:text-foreground",
42-
]}
43-
aria-current={isActive ? "page" : undefined}
44-
>
45-
{link.label}
46-
</a>
47-
</li>
48-
);
49-
})}
50-
</ul>
31+
hasDropdown ? (
32+
<DesktopNav links={links} currentPath={currentPath} client:load />
33+
) : (
34+
links.length > 0 && (
35+
<ul class="hidden items-center gap-6 md:flex">
36+
{links.map((link) => {
37+
const isActive =
38+
currentPath === link.href || currentPath === link.href + "/";
39+
return (
40+
<li>
41+
<a
42+
href={link.href}
43+
class:list={[
44+
"text-sm transition-colors",
45+
isActive
46+
? "text-foreground font-medium"
47+
: "text-muted-foreground hover:text-foreground",
48+
]}
49+
aria-current={isActive ? "page" : undefined}
50+
>
51+
{link.label}
52+
</a>
53+
</li>
54+
);
55+
})}
56+
</ul>
57+
)
5158
)
5259
}
5360

src/components/MobileNav.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@ import {
1010
SheetTitle,
1111
SheetTrigger,
1212
} from "@/components/ui/sheet";
13-
14-
interface NavLink {
15-
label: string;
16-
href: string;
17-
}
13+
import type { NavLink } from "@/config";
1814

1915
export function MobileNav({ links, name }: { links: NavLink[]; name: string }) {
2016
return (
@@ -39,15 +35,34 @@ export function MobileNav({ links, name }: { links: NavLink[]; name: string }) {
3935
</SheetHeader>
4036

4137
<nav className="flex flex-1 flex-col gap-1 px-4 py-4">
42-
{links.map((link) => (
43-
<a
44-
key={link.href}
45-
href={link.href}
46-
className="text-foreground hover:bg-muted rounded-md px-3 py-3 text-base font-medium transition-colors"
47-
>
48-
{link.label}
49-
</a>
50-
))}
38+
{links.map((link) =>
39+
link.items && link.items.length > 0 ? (
40+
<div key={link.label} className="py-1">
41+
<p className="text-muted-foreground px-3 py-2 text-sm font-medium">
42+
{link.label}
43+
</p>
44+
<div className="flex flex-col gap-0.5">
45+
{link.items.map((item) => (
46+
<a
47+
key={item.href}
48+
href={item.href}
49+
className="text-foreground hover:bg-muted rounded-md px-3 py-2.5 pl-6 text-base transition-colors"
50+
>
51+
{item.label}
52+
</a>
53+
))}
54+
</div>
55+
</div>
56+
) : (
57+
<a
58+
key={link.href}
59+
href={link.href}
60+
className="text-foreground hover:bg-muted rounded-md px-3 py-3 text-base font-medium transition-colors"
61+
>
62+
{link.label}
63+
</a>
64+
),
65+
)}
5166
</nav>
5267
</div>
5368
</SheetContent>

src/config.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,43 @@
1010
* - analytics.matomoUrl / analytics.matomoSiteId: Matomo-Tracking
1111
* - smtp: SMTP-Zugangsdaten für Kontaktformular (nodemailer)
1212
*/
13+
14+
/**
15+
* Ein Navigationslink (Header oder Footer).
16+
*
17+
* - Einfacher Link: `{ label, href }`
18+
* - Dropdown (Hover-Menü mit Unterlinks): `{ label, items: [...] }`
19+
* Der `label`-Eintrag öffnet dann das Menü; `href` ist optional
20+
* (z.B. für eine Übersichtsseite).
21+
*/
22+
export interface NavLink {
23+
label: string;
24+
href?: string;
25+
items?: { label: string; href: string }[];
26+
}
27+
1328
export const config = {
1429
name: "Seitenname",
1530
tagline: "Ihr Tagline",
1631
// Keep in sync with astro.config.mjs
1732
site: "https://example.com",
1833
navigation: {
19-
header: [{ label: "Startseite", href: "/" }],
34+
header: [
35+
{ label: "Startseite", href: "/" },
36+
{
37+
label: "Produkte",
38+
items: [
39+
{ label: "Analytics", href: "/analytics" },
40+
{ label: "Automation", href: "/automation" },
41+
{ label: "Integrationen", href: "/integrationen" },
42+
],
43+
},
44+
] as NavLink[],
2045
footer: [
2146
{ label: "Kontakt", href: "/kontakt" },
2247
{ label: "Impressum", href: "/impressum" },
2348
{ label: "Datenschutz", href: "/datenschutz" },
24-
],
49+
] as NavLink[],
2550
},
2651
smtp: {
2752
host: "",

0 commit comments

Comments
 (0)