Skip to content

Commit 03dc15c

Browse files
nullcoderclaude
andauthored
feat: implement header component with navigation (#83)
* feat: implement header component with navigation - Add sticky header with GhostPaste branding and ghost icon - Implement desktop navigation with Create, About, and GitHub links - Add mobile-responsive hamburger menu for screens < 768px - Integrate theme toggle in both desktop and mobile views - Include accessibility features (skip link, ARIA labels, keyboard nav) - Install shadcn/ui navigation-menu and sheet components - Create custom GitHub icon using Simple Icons SVG - Add comprehensive test suite for header functionality - Remove redundant header from home page - Create demo page for testing header responsiveness Closes #53 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * docs: mark header component task as complete in TODO.md --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60c5cb4 commit 03dc15c

File tree

11 files changed

+818
-13
lines changed

11 files changed

+818
-13
lines changed

app/demo/header/page.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
5+
export default function HeaderDemo() {
6+
const [viewportWidth, setViewportWidth] = useState(0);
7+
8+
useEffect(() => {
9+
// Set initial width
10+
setViewportWidth(window.innerWidth);
11+
12+
// Update viewport width on resize
13+
const handleResize = () => {
14+
setViewportWidth(window.innerWidth);
15+
};
16+
17+
window.addEventListener("resize", handleResize);
18+
return () => window.removeEventListener("resize", handleResize);
19+
}, []);
20+
21+
return (
22+
<div className="container mx-auto p-8">
23+
<h1 className="mb-4 text-2xl font-bold">Header Component Demo</h1>
24+
25+
<div className="bg-muted mb-8 rounded-lg p-4">
26+
<p className="text-muted-foreground mb-2 text-sm">
27+
Current viewport width:{" "}
28+
<span className="font-mono">{viewportWidth}px</span>
29+
</p>
30+
<p className="text-muted-foreground text-sm">
31+
Mobile menu appears at: <span className="font-mono">&lt;768px</span>
32+
</p>
33+
</div>
34+
35+
<div className="space-y-8">
36+
<section>
37+
<h2 className="mb-4 text-xl font-semibold">Features to Test</h2>
38+
<ul className="list-inside list-disc space-y-2 text-sm">
39+
<li>Resize browser window to see mobile menu (hamburger) appear</li>
40+
<li>Click hamburger menu to open mobile navigation drawer</li>
41+
<li>Test theme toggle in both desktop and mobile views</li>
42+
<li>Check sticky header behavior by scrolling</li>
43+
<li>Verify all navigation links work correctly</li>
44+
<li>Test keyboard navigation (Tab, Enter, Escape)</li>
45+
<li>
46+
Try &quot;Skip to main content&quot; link (visible on focus)
47+
</li>
48+
</ul>
49+
</section>
50+
51+
<section>
52+
<h2 className="mb-4 text-xl font-semibold">Accessibility Features</h2>
53+
<ul className="list-inside list-disc space-y-2 text-sm">
54+
<li>Press Tab to navigate through interactive elements</li>
55+
<li>
56+
First Tab press reveals &quot;Skip to main content&quot; link
57+
</li>
58+
<li>All buttons have proper ARIA labels</li>
59+
<li>Mobile menu can be closed with Escape key</li>
60+
<li>Theme toggle has screen reader text</li>
61+
</ul>
62+
</section>
63+
64+
<section>
65+
<h2 className="mb-4 text-xl font-semibold">
66+
Long Content for Scroll Testing
67+
</h2>
68+
<div className="space-y-4">
69+
{[...Array(20)].map((_, i) => (
70+
<p key={i} className="bg-muted rounded p-4">
71+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
72+
eiusmod tempor incididunt ut labore et dolore magna aliqua.
73+
Paragraph {i + 1} of 20
74+
</p>
75+
))}
76+
</div>
77+
</section>
78+
</div>
79+
</div>
80+
);
81+
}

app/layout.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import { ThemeProvider } from "@/components/theme-provider";
4+
import { Header } from "@/components/header";
45
import "./globals.css";
56

67
const geistSans = Geist({
@@ -27,15 +28,18 @@ export default function RootLayout({
2728
return (
2829
<html lang="en" suppressHydrationWarning>
2930
<body
30-
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
31+
className={`${geistSans.variable} ${geistMono.variable} flex min-h-screen flex-col antialiased`}
3132
>
3233
<ThemeProvider
3334
attribute="class"
3435
defaultTheme="system"
3536
enableSystem
3637
disableTransitionOnChange
3738
>
38-
{children}
39+
<Header />
40+
<main id="main-content" className="flex-1">
41+
{children}
42+
</main>
3943
</ThemeProvider>
4044
</body>
4145
</html>

app/page.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import { ThemeToggle } from "@/components/theme-toggle";
21
import { R2Test } from "@/components/r2-test";
32

43
export default function Home() {
54
return (
65
<div className="min-h-screen p-8">
7-
<header className="mb-8 flex items-center justify-between">
8-
<h1 className="text-3xl font-bold">GhostPaste</h1>
9-
<ThemeToggle />
10-
</header>
116
<main className="mx-auto max-w-4xl">
127
<div className="bg-card text-card-foreground rounded-lg border p-6 shadow-sm">
138
<h2 className="mb-4 text-2xl font-semibold">
@@ -25,8 +20,8 @@ export default function Home() {
2520
</code>
2621
</p>
2722
<p>
28-
Click the theme toggle button in the header to switch between
29-
light and dark modes.
23+
Click the theme toggle button in the navigation bar to switch
24+
between light and dark modes.
3025
</p>
3126
</div>
3227
</div>

components/header.test.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { Header } from "./header";
5+
6+
// Mock next/link
7+
vi.mock("next/link", () => ({
8+
default: ({
9+
children,
10+
href,
11+
...props
12+
}: {
13+
children: React.ReactNode;
14+
href: string;
15+
}) => (
16+
<a href={href} {...props}>
17+
{children}
18+
</a>
19+
),
20+
}));
21+
22+
// Mock next-themes
23+
vi.mock("next-themes", () => ({
24+
useTheme: () => ({
25+
theme: "light",
26+
setTheme: vi.fn(),
27+
}),
28+
}));
29+
30+
describe("Header", () => {
31+
it("renders with logo and navigation", () => {
32+
render(<Header />);
33+
34+
// Check logo
35+
expect(screen.getByText("GhostPaste")).toBeInTheDocument();
36+
37+
// Check desktop navigation links
38+
expect(screen.getByRole("link", { name: "Create" })).toBeInTheDocument();
39+
expect(screen.getByRole("link", { name: "About" })).toBeInTheDocument();
40+
expect(screen.getByText("GitHub")).toBeInTheDocument();
41+
42+
// Check theme toggles (both desktop and mobile)
43+
const themeToggles = screen.getAllByRole("button", {
44+
name: "Toggle theme",
45+
});
46+
expect(themeToggles).toHaveLength(2); // One for desktop, one for mobile
47+
});
48+
49+
it("includes skip to main content link", () => {
50+
render(<Header />);
51+
52+
const skipLink = screen.getByText("Skip to main content");
53+
expect(skipLink).toBeInTheDocument();
54+
expect(skipLink).toHaveClass("sr-only");
55+
expect(skipLink).toHaveAttribute("href", "#main-content");
56+
});
57+
58+
it("shows mobile menu button on small screens", () => {
59+
render(<Header />);
60+
61+
const mobileMenuButton = screen.getByRole("button", {
62+
name: "Open navigation menu",
63+
});
64+
expect(mobileMenuButton).toBeInTheDocument();
65+
expect(mobileMenuButton).toHaveClass("md:hidden");
66+
});
67+
68+
it("opens and closes mobile menu", async () => {
69+
const user = userEvent.setup();
70+
render(<Header />);
71+
72+
const mobileMenuButton = screen.getByRole("button", {
73+
name: "Open navigation menu",
74+
});
75+
76+
// Open menu
77+
await user.click(mobileMenuButton);
78+
79+
// Check if sheet content is visible
80+
expect(screen.getByRole("dialog")).toBeInTheDocument();
81+
82+
// Check mobile navigation links
83+
const mobileNav = screen.getByRole("dialog");
84+
expect(mobileNav).toContainElement(screen.getAllByText("Create")[1]);
85+
expect(mobileNav).toContainElement(screen.getAllByText("About")[1]);
86+
expect(mobileNav).toContainElement(screen.getAllByText("GitHub")[1]);
87+
88+
// Close menu by clicking a link
89+
const createLink = screen.getAllByText("Create")[1];
90+
await user.click(createLink);
91+
92+
// Menu should be closed (dialog removed from DOM)
93+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
94+
});
95+
96+
it("has correct link hrefs", () => {
97+
render(<Header />);
98+
99+
// Desktop links
100+
const desktopCreateLink = screen.getByRole("link", { name: "Create" });
101+
expect(desktopCreateLink).toHaveAttribute("href", "/create");
102+
103+
const desktopAboutLink = screen.getByRole("link", { name: "About" });
104+
expect(desktopAboutLink).toHaveAttribute("href", "/about");
105+
106+
// GitHub link (external)
107+
const githubLinks = screen.getAllByRole("link", { name: /GitHub/i });
108+
githubLinks.forEach((link) => {
109+
expect(link).toHaveAttribute(
110+
"href",
111+
"https://github.com/nullcoder/ghostpaste"
112+
);
113+
expect(link).toHaveAttribute("target", "_blank");
114+
expect(link).toHaveAttribute("rel", "noopener noreferrer");
115+
});
116+
});
117+
118+
it("logo links to home page", () => {
119+
render(<Header />);
120+
121+
const logoLink = screen.getByRole("link", { name: /GhostPaste/i });
122+
expect(logoLink).toHaveAttribute("href", "/");
123+
});
124+
125+
it("has sticky positioning", () => {
126+
render(<Header />);
127+
128+
const header = screen.getByRole("banner");
129+
expect(header).toHaveClass("sticky", "top-0", "z-50");
130+
});
131+
132+
it("applies proper backdrop blur", () => {
133+
render(<Header />);
134+
135+
const header = screen.getByRole("banner");
136+
expect(header).toHaveClass(
137+
"bg-background/95",
138+
"backdrop-blur",
139+
"supports-[backdrop-filter]:bg-background/60"
140+
);
141+
});
142+
143+
it("mobile menu closes on escape key", async () => {
144+
const user = userEvent.setup();
145+
render(<Header />);
146+
147+
const mobileMenuButton = screen.getByRole("button", {
148+
name: "Open navigation menu",
149+
});
150+
151+
// Open menu
152+
await user.click(mobileMenuButton);
153+
expect(screen.getByRole("dialog")).toBeInTheDocument();
154+
155+
// Press escape
156+
await user.keyboard("{Escape}");
157+
158+
// Menu should be closed
159+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
160+
});
161+
162+
it("mobile menu has proper aria labels", async () => {
163+
const user = userEvent.setup();
164+
render(<Header />);
165+
166+
const mobileMenuButton = screen.getByRole("button", {
167+
name: "Open navigation menu",
168+
});
169+
170+
await user.click(mobileMenuButton);
171+
172+
const dialog = screen.getByRole("dialog");
173+
expect(dialog).toBeInTheDocument();
174+
175+
// Check close button
176+
const closeButton = screen.getByRole("button", { name: "Close" });
177+
expect(closeButton).toBeInTheDocument();
178+
});
179+
});

0 commit comments

Comments
 (0)