Skip to content

Commit 8efb93a

Browse files
feat(navbar): add nested navigation support (#27)
* feat(navbar): add nested navigation support Support one level of navbar children with a desktop dropdown and a mobile sidebar so projects can group related pages without changing the existing link API. * fix(solutions): align new page content with global layout * fix(content): left-align blog and resources layouts * refactor(navbar): add shared link normalization model * refactor(navbar): use normalized links for desktop menu * refactor(navbar): share normalized links with mobile menu * refactor(navbar): extract desktop dropdown link styles * test(navbar): add runnable unit test setup --------- Co-authored-by: Martin Wagner <22003632+Waldleufer@users.noreply.github.com>
1 parent 9f23ced commit 8efb93a

17 files changed

Lines changed: 577 additions & 55 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"astro": "astro",
1212
"lint": "eslint src --ext .ts,.tsx,.astro",
1313
"typecheck": "astro check",
14+
"test:unit": "vitest run --config vitest.unit.config.ts",
1415
"format": "prettier --write \"src/**/*.{ts,tsx,astro,css,md,mdx}\"",
1516
"storybook": "storybook dev -p 6006",
1617
"storybook:build": "storybook build"

src/components/organisms/Navbar/Navbar.stories.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,32 @@ type Story = StoryObj<typeof meta>;
3131

3232
export const Default: Story = {};
3333

34+
export const WithDropdown: Story = {
35+
args: {
36+
links: [
37+
{ label: "About", href: "/about" },
38+
{
39+
label: "Solutions",
40+
href: "/solutions",
41+
items: [
42+
{ label: "Discovery", href: "/solutions/discovery" },
43+
{ label: "Synthesis", href: "/solutions/synthesis" },
44+
],
45+
},
46+
{ label: "Resources", href: "/resources" },
47+
],
48+
},
49+
};
50+
51+
export const MobileDropdown: Story = {
52+
args: WithDropdown.args,
53+
parameters: {
54+
viewport: {
55+
defaultViewport: "mobile1",
56+
},
57+
},
58+
};
59+
3460
export const NavigatesToLink: Story = {
3561
play: async ({ canvasElement }) => {
3662
const canvas = within(canvasElement);
@@ -40,3 +66,45 @@ export const NavigatesToLink: Story = {
4066
await expect(link).toHaveAttribute("href", "/about");
4167
},
4268
};
69+
70+
export const OpensDropdown: Story = {
71+
args: WithDropdown.args,
72+
play: async ({ canvasElement }) => {
73+
const canvas = within(canvasElement);
74+
const trigger = canvas.getByRole("link", { name: "Solutions" });
75+
76+
trigger.focus();
77+
await expect(
78+
canvas.getByRole("link", { name: "Discovery" }),
79+
).toHaveAttribute("href", "/solutions/discovery");
80+
},
81+
};
82+
83+
export const OpensMobileSidebar: Story = {
84+
args: WithDropdown.args,
85+
parameters: {
86+
viewport: {
87+
defaultViewport: "mobile1",
88+
},
89+
},
90+
play: async ({ canvasElement }) => {
91+
const canvas = within(canvasElement);
92+
const menuButton = canvas.getByText("Open menu").closest("label");
93+
94+
if (!menuButton) {
95+
throw new Error("Open menu control not found.");
96+
}
97+
98+
await userEvent.click(menuButton);
99+
const toggle = canvasElement.querySelector(
100+
"#mobile-navigation-toggle",
101+
) as HTMLInputElement | null;
102+
103+
if (!toggle) {
104+
throw new Error("Mobile navigation toggle not found.");
105+
}
106+
107+
await expect(toggle.checked).toBe(true);
108+
await expect(canvas.getByLabelText("Navigation menu")).toBeInTheDocument();
109+
},
110+
};
Lines changed: 208 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,214 @@
11
import { withBase } from "@/components/utils/with-base";
2+
import {
3+
normalizeNavbarLinks,
4+
type NormalizedNavbarItem,
5+
} from "./Navbar.utils";
26
import type { NavbarProps } from "./Navbar.types";
37

4-
export const Navbar = ({ siteName, links }: NavbarProps) => (
5-
<header className="sticky top-0 z-50 border-b border-secondary-200 bg-white/80 backdrop-blur-sm">
6-
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
7-
<a
8-
href={withBase("/")}
9-
className="text-xl font-bold text-primary-600 hover:text-primary-700"
10-
>
11-
{siteName}
12-
</a>
13-
<ul className="flex items-center gap-6 text-sm font-medium">
14-
{links.map((link) => (
15-
<li key={`${link.label}-${link.href}`}>
16-
<a href={withBase(link.href)} className="hover:text-primary-600">
17-
{link.label}
8+
interface MobileLinkListProps {
9+
links: readonly NormalizedNavbarItem[];
10+
}
11+
12+
const desktopLinkClassName =
13+
"inline-flex items-center gap-2 rounded-full px-3 py-2 transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2";
14+
15+
const mobileLinkClassName =
16+
"block rounded-lg px-3 py-2 text-base transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2";
17+
18+
const desktopDropdownLinkClassName =
19+
"block rounded-xl px-3 py-2 transition-colors hover:bg-secondary-50 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2";
20+
21+
const MobileLinkList = ({ links }: MobileLinkListProps) => (
22+
<ul className="flex flex-col gap-2 text-sm font-medium text-secondary-900">
23+
{links.map((item) => {
24+
if (item.type === "single") {
25+
return (
26+
<li key={item.key}>
27+
<a href={withBase(item.link.href)} className={mobileLinkClassName}>
28+
{item.link.label}
1829
</a>
1930
</li>
20-
))}
21-
</ul>
22-
</nav>
23-
</header>
31+
);
32+
}
33+
34+
return (
35+
<li key={item.key}>
36+
<details className="group">
37+
<summary
38+
className={`${mobileLinkClassName} flex cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden`}
39+
>
40+
<span>{item.trigger.label}</span>
41+
<svg
42+
aria-hidden="true"
43+
viewBox="0 0 10 6"
44+
className="h-2.5 w-2.5 transition-transform group-open:rotate-180"
45+
>
46+
<path
47+
d="M1 1L5 5L9 1"
48+
fill="none"
49+
stroke="currentColor"
50+
strokeWidth="1.5"
51+
strokeLinecap="round"
52+
strokeLinejoin="round"
53+
/>
54+
</svg>
55+
</summary>
56+
<ul className="mt-1 flex flex-col gap-1 border-l border-secondary-200 pl-4">
57+
{item.links.map((subItem) => (
58+
<li key={subItem.key}>
59+
<a
60+
href={withBase(subItem.href)}
61+
className={mobileLinkClassName}
62+
>
63+
{subItem.label}
64+
</a>
65+
</li>
66+
))}
67+
</ul>
68+
</details>
69+
</li>
70+
);
71+
})}
72+
</ul>
2473
);
74+
75+
export const Navbar = ({ siteName, links }: NavbarProps) => {
76+
const normalizedLinks = normalizeNavbarLinks(links);
77+
78+
return (
79+
<div className="relative">
80+
<input
81+
id="mobile-navigation-toggle"
82+
type="checkbox"
83+
className="peer sr-only"
84+
aria-hidden="true"
85+
tabIndex={-1}
86+
/>
87+
88+
<header className="sticky top-0 z-40 border-b border-secondary-200 bg-white/80 backdrop-blur-sm">
89+
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
90+
<a
91+
href={withBase("/")}
92+
className="text-xl font-bold text-primary-600 transition-colors hover:text-primary-700"
93+
>
94+
{siteName}
95+
</a>
96+
97+
<label
98+
htmlFor="mobile-navigation-toggle"
99+
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-secondary-200 text-secondary-900 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 sm:hidden"
100+
aria-controls="mobile-navigation"
101+
tabIndex={0}
102+
>
103+
<span className="sr-only">Open menu</span>
104+
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5">
105+
<path
106+
d="M4 7H20M4 12H20M4 17H20"
107+
fill="none"
108+
stroke="currentColor"
109+
strokeWidth="1.8"
110+
strokeLinecap="round"
111+
/>
112+
</svg>
113+
</label>
114+
115+
<ul className="hidden items-center gap-3 text-sm font-medium sm:flex">
116+
{normalizedLinks.map((item) => {
117+
if (item.type === "single") {
118+
return (
119+
<li key={item.key}>
120+
<a
121+
href={withBase(item.link.href)}
122+
className={desktopLinkClassName}
123+
>
124+
{item.link.label}
125+
</a>
126+
</li>
127+
);
128+
}
129+
130+
return (
131+
<li key={item.key} className="group relative">
132+
<a
133+
href={withBase(item.trigger.href)}
134+
className={desktopLinkClassName}
135+
>
136+
<span>{item.trigger.label}</span>
137+
<svg
138+
aria-hidden="true"
139+
viewBox="0 0 10 6"
140+
className="h-2.5 w-2.5 transition-transform group-hover:rotate-180 group-focus-within:rotate-180"
141+
>
142+
<path
143+
d="M1 1L5 5L9 1"
144+
fill="none"
145+
stroke="currentColor"
146+
strokeWidth="1.5"
147+
strokeLinecap="round"
148+
strokeLinejoin="round"
149+
/>
150+
</svg>
151+
</a>
152+
<div className="pointer-events-none invisible absolute right-0 top-full pt-3 opacity-0 transition duration-150 group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100">
153+
<ul className="min-w-48 rounded-2xl border border-secondary-200 bg-white p-2 text-secondary-900 shadow-lg shadow-secondary-900/10">
154+
{item.links.map((subItem) => (
155+
<li key={subItem.key}>
156+
<a
157+
href={withBase(subItem.href)}
158+
className={desktopDropdownLinkClassName}
159+
>
160+
{subItem.label}
161+
</a>
162+
</li>
163+
))}
164+
</ul>
165+
</div>
166+
</li>
167+
);
168+
})}
169+
</ul>
170+
</nav>
171+
</header>
172+
173+
<div
174+
className="pointer-events-none fixed inset-0 z-50 bg-secondary-900/40 opacity-0 transition-opacity duration-200 peer-checked:pointer-events-auto peer-checked:opacity-100 sm:hidden"
175+
aria-hidden="true"
176+
/>
177+
178+
<aside
179+
id="mobile-navigation"
180+
aria-label="Navigation menu"
181+
className="fixed right-0 top-0 z-50 flex h-full w-[min(24rem,100vw)] translate-x-full flex-col border-l border-secondary-200 bg-white px-5 py-5 shadow-2xl transition-transform duration-200 peer-checked:translate-x-0 sm:hidden"
182+
>
183+
<div className="flex items-center justify-between gap-4 border-b border-secondary-200 pb-4">
184+
<a
185+
href={withBase("/")}
186+
className="text-lg font-bold text-primary-600"
187+
>
188+
{siteName}
189+
</a>
190+
<label
191+
htmlFor="mobile-navigation-toggle"
192+
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-secondary-200 text-secondary-900 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
193+
tabIndex={0}
194+
>
195+
<span className="sr-only">Close menu</span>
196+
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5">
197+
<path
198+
d="M6 6L18 18M18 6L6 18"
199+
fill="none"
200+
stroke="currentColor"
201+
strokeWidth="1.8"
202+
strokeLinecap="round"
203+
/>
204+
</svg>
205+
</label>
206+
</div>
207+
208+
<div className="mt-6 overflow-y-auto">
209+
<MobileLinkList links={normalizedLinks} />
210+
</div>
211+
</aside>
212+
</div>
213+
);
214+
};

src/components/organisms/Navbar/Navbar.types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
export interface NavbarLinkItem {
1+
interface NavbarLinkShape {
22
label: string;
33
href: string;
4+
items?: readonly NavbarSubLinkItem[];
45
}
56

7+
export type NavbarSubLinkItem = Omit<NavbarLinkShape, "items">;
8+
9+
export interface NavbarLinkItem extends NavbarLinkShape {}
10+
611
export interface NavbarProps {
712
siteName: string;
813
links: readonly NavbarLinkItem[];
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { normalizeNavbarLinks } from "./Navbar.utils";
4+
5+
describe("normalizeNavbarLinks", () => {
6+
it("returns single item links unchanged", () => {
7+
const items = normalizeNavbarLinks([{ label: "About", href: "/about" }]);
8+
9+
expect(items).toEqual([
10+
{
11+
type: "single",
12+
key: "About-/about",
13+
link: {
14+
key: "About-/about",
15+
label: "About",
16+
href: "/about",
17+
},
18+
},
19+
]);
20+
});
21+
22+
it("adds overview first for grouped links", () => {
23+
const items = normalizeNavbarLinks([
24+
{
25+
label: "Solutions",
26+
href: "/solutions",
27+
items: [
28+
{ label: "Discovery", href: "/solutions/discovery" },
29+
{ label: "Synthesis", href: "/solutions/synthesis" },
30+
],
31+
},
32+
]);
33+
34+
expect(items).toEqual([
35+
{
36+
type: "group",
37+
key: "Solutions-/solutions",
38+
trigger: {
39+
key: "Solutions-/solutions",
40+
label: "Solutions",
41+
href: "/solutions",
42+
},
43+
links: [
44+
{
45+
key: "Overview-/solutions",
46+
label: "Overview",
47+
href: "/solutions",
48+
},
49+
{
50+
key: "Discovery-/solutions/discovery",
51+
label: "Discovery",
52+
href: "/solutions/discovery",
53+
},
54+
{
55+
key: "Synthesis-/solutions/synthesis",
56+
label: "Synthesis",
57+
href: "/solutions/synthesis",
58+
},
59+
],
60+
},
61+
]);
62+
});
63+
});

0 commit comments

Comments
 (0)