Skip to content

feat: implement header component with navigation #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions app/demo/header/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import { useState, useEffect } from "react";

export default function HeaderDemo() {
const [viewportWidth, setViewportWidth] = useState(0);

useEffect(() => {
// Set initial width
setViewportWidth(window.innerWidth);

// Update viewport width on resize
const handleResize = () => {
setViewportWidth(window.innerWidth);
};

window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);

return (
<div className="container mx-auto p-8">
<h1 className="mb-4 text-2xl font-bold">Header Component Demo</h1>

<div className="bg-muted mb-8 rounded-lg p-4">
<p className="text-muted-foreground mb-2 text-sm">
Current viewport width:{" "}
<span className="font-mono">{viewportWidth}px</span>
</p>
<p className="text-muted-foreground text-sm">
Mobile menu appears at: <span className="font-mono">&lt;768px</span>
</p>
</div>

<div className="space-y-8">
<section>
<h2 className="mb-4 text-xl font-semibold">Features to Test</h2>
<ul className="list-inside list-disc space-y-2 text-sm">
<li>Resize browser window to see mobile menu (hamburger) appear</li>
<li>Click hamburger menu to open mobile navigation drawer</li>
<li>Test theme toggle in both desktop and mobile views</li>
<li>Check sticky header behavior by scrolling</li>
<li>Verify all navigation links work correctly</li>
<li>Test keyboard navigation (Tab, Enter, Escape)</li>
<li>
Try &quot;Skip to main content&quot; link (visible on focus)
</li>
</ul>
</section>

<section>
<h2 className="mb-4 text-xl font-semibold">Accessibility Features</h2>
<ul className="list-inside list-disc space-y-2 text-sm">
<li>Press Tab to navigate through interactive elements</li>
<li>
First Tab press reveals &quot;Skip to main content&quot; link
</li>
<li>All buttons have proper ARIA labels</li>
<li>Mobile menu can be closed with Escape key</li>
<li>Theme toggle has screen reader text</li>
</ul>
</section>

<section>
<h2 className="mb-4 text-xl font-semibold">
Long Content for Scroll Testing
</h2>
<div className="space-y-4">
{[...Array(20)].map((_, i) => (
<p key={i} className="bg-muted rounded p-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
Paragraph {i + 1} of 20
</p>
))}
</div>
</section>
</div>
</div>
);
}
8 changes: 6 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Header } from "@/components/header";
import "./globals.css";

const geistSans = Geist({
Expand All @@ -27,15 +28,18 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} flex min-h-screen flex-col antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Header />
<main id="main-content" className="flex-1">
{children}
</main>
</ThemeProvider>
</body>
</html>
Expand Down
9 changes: 2 additions & 7 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { ThemeToggle } from "@/components/theme-toggle";
import { R2Test } from "@/components/r2-test";

export default function Home() {
return (
<div className="min-h-screen p-8">
<header className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold">GhostPaste</h1>
<ThemeToggle />
</header>
<main className="mx-auto max-w-4xl">
<div className="bg-card text-card-foreground rounded-lg border p-6 shadow-sm">
<h2 className="mb-4 text-2xl font-semibold">
Expand All @@ -25,8 +20,8 @@ export default function Home() {
</code>
</p>
<p>
Click the theme toggle button in the header to switch between
light and dark modes.
Click the theme toggle button in the navigation bar to switch
between light and dark modes.
</p>
</div>
</div>
Expand Down
179 changes: 179 additions & 0 deletions components/header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Header } from "./header";

// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));

// Mock next-themes
vi.mock("next-themes", () => ({
useTheme: () => ({
theme: "light",
setTheme: vi.fn(),
}),
}));

describe("Header", () => {
it("renders with logo and navigation", () => {
render(<Header />);

// Check logo
expect(screen.getByText("GhostPaste")).toBeInTheDocument();

// Check desktop navigation links
expect(screen.getByRole("link", { name: "Create" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "About" })).toBeInTheDocument();
expect(screen.getByText("GitHub")).toBeInTheDocument();

// Check theme toggles (both desktop and mobile)
const themeToggles = screen.getAllByRole("button", {
name: "Toggle theme",
});
expect(themeToggles).toHaveLength(2); // One for desktop, one for mobile
});

it("includes skip to main content link", () => {
render(<Header />);

const skipLink = screen.getByText("Skip to main content");
expect(skipLink).toBeInTheDocument();
expect(skipLink).toHaveClass("sr-only");
expect(skipLink).toHaveAttribute("href", "#main-content");
});

it("shows mobile menu button on small screens", () => {
render(<Header />);

const mobileMenuButton = screen.getByRole("button", {
name: "Open navigation menu",
});
expect(mobileMenuButton).toBeInTheDocument();
expect(mobileMenuButton).toHaveClass("md:hidden");
});

it("opens and closes mobile menu", async () => {
const user = userEvent.setup();
render(<Header />);

const mobileMenuButton = screen.getByRole("button", {
name: "Open navigation menu",
});

// Open menu
await user.click(mobileMenuButton);

// Check if sheet content is visible
expect(screen.getByRole("dialog")).toBeInTheDocument();

// Check mobile navigation links
const mobileNav = screen.getByRole("dialog");
expect(mobileNav).toContainElement(screen.getAllByText("Create")[1]);
expect(mobileNav).toContainElement(screen.getAllByText("About")[1]);
expect(mobileNav).toContainElement(screen.getAllByText("GitHub")[1]);

// Close menu by clicking a link
const createLink = screen.getAllByText("Create")[1];
await user.click(createLink);

// Menu should be closed (dialog removed from DOM)
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("has correct link hrefs", () => {
render(<Header />);

// Desktop links
const desktopCreateLink = screen.getByRole("link", { name: "Create" });
expect(desktopCreateLink).toHaveAttribute("href", "/create");

const desktopAboutLink = screen.getByRole("link", { name: "About" });
expect(desktopAboutLink).toHaveAttribute("href", "/about");

// GitHub link (external)
const githubLinks = screen.getAllByRole("link", { name: /GitHub/i });
githubLinks.forEach((link) => {
expect(link).toHaveAttribute(
"href",
"https://github.com/nullcoder/ghostpaste"
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});

it("logo links to home page", () => {
render(<Header />);

const logoLink = screen.getByRole("link", { name: /GhostPaste/i });
expect(logoLink).toHaveAttribute("href", "/");
});

it("has sticky positioning", () => {
render(<Header />);

const header = screen.getByRole("banner");
expect(header).toHaveClass("sticky", "top-0", "z-50");
});

it("applies proper backdrop blur", () => {
render(<Header />);

const header = screen.getByRole("banner");
expect(header).toHaveClass(
"bg-background/95",
"backdrop-blur",
"supports-[backdrop-filter]:bg-background/60"
);
});

it("mobile menu closes on escape key", async () => {
const user = userEvent.setup();
render(<Header />);

const mobileMenuButton = screen.getByRole("button", {
name: "Open navigation menu",
});

// Open menu
await user.click(mobileMenuButton);
expect(screen.getByRole("dialog")).toBeInTheDocument();

// Press escape
await user.keyboard("{Escape}");

// Menu should be closed
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("mobile menu has proper aria labels", async () => {
const user = userEvent.setup();
render(<Header />);

const mobileMenuButton = screen.getByRole("button", {
name: "Open navigation menu",
});

await user.click(mobileMenuButton);

const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();

// Check close button
const closeButton = screen.getByRole("button", { name: "Close" });
expect(closeButton).toBeInTheDocument();
});
});
Loading