+ Current viewport width:{" "}
+ {viewportWidth}px
+
+
+ Mobile menu appears at: <768px
+
+
+
+
+
+
Features to Test
+
+
Resize browser window to see mobile menu (hamburger) appear
+
Click hamburger menu to open mobile navigation drawer
+
Test theme toggle in both desktop and mobile views
+
Check sticky header behavior by scrolling
+
Verify all navigation links work correctly
+
Test keyboard navigation (Tab, Enter, Escape)
+
+ Try "Skip to main content" link (visible on focus)
+
+
+
+
+
+
Accessibility Features
+
+
Press Tab to navigate through interactive elements
+
+ First Tab press reveals "Skip to main content" link
+
+
All buttons have proper ARIA labels
+
Mobile menu can be closed with Escape key
+
Theme toggle has screen reader text
+
+
+
+
+
+ Long Content for Scroll Testing
+
+
+ {[...Array(20)].map((_, i) => (
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
+ Paragraph {i + 1} of 20
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 28c2329..ec259b2 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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({
@@ -27,7 +28,7 @@ export default function RootLayout({
return (
- {children}
+
+
+ {children}
+
diff --git a/app/page.tsx b/app/page.tsx
index c012f26..c4ef113 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,13 +1,8 @@
-import { ThemeToggle } from "@/components/theme-toggle";
import { R2Test } from "@/components/r2-test";
export default function Home() {
return (
-
-
GhostPaste
-
-
@@ -25,8 +20,8 @@ export default function Home() {
- 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.
diff --git a/components/header.test.tsx b/components/header.test.tsx
new file mode 100644
index 0000000..eaca5bf
--- /dev/null
+++ b/components/header.test.tsx
@@ -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;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock next-themes
+vi.mock("next-themes", () => ({
+ useTheme: () => ({
+ theme: "light",
+ setTheme: vi.fn(),
+ }),
+}));
+
+describe("Header", () => {
+ it("renders with logo and navigation", () => {
+ render();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ const logoLink = screen.getByRole("link", { name: /GhostPaste/i });
+ expect(logoLink).toHaveAttribute("href", "/");
+ });
+
+ it("has sticky positioning", () => {
+ render();
+
+ const header = screen.getByRole("banner");
+ expect(header).toHaveClass("sticky", "top-0", "z-50");
+ });
+
+ it("applies proper backdrop blur", () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+ });
+});
diff --git a/components/header.tsx b/components/header.tsx
new file mode 100644
index 0000000..cba4669
--- /dev/null
+++ b/components/header.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { Menu, Ghost } from "lucide-react";
+import { GithubIcon } from "@/components/icons/github-icon";
+import {
+ NavigationMenu,
+ NavigationMenuList,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+ SheetClose,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { cn } from "@/lib/utils";
+
+export function Header() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ {/* Skip to main content link for accessibility */}
+
+ Skip to main content
+
+
+