diff --git a/app/demo/header/page.tsx b/app/demo/header/page.tsx new file mode 100644 index 0000000..a573571 --- /dev/null +++ b/app/demo/header/page.tsx @@ -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 ( +
+

Header Component Demo

+ +
+

+ 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 + + +
+ {/* Logo/Branding */} + +
+
+ ); +} diff --git a/components/icons/github-icon.tsx b/components/icons/github-icon.tsx new file mode 100644 index 0000000..d744ac0 --- /dev/null +++ b/components/icons/github-icon.tsx @@ -0,0 +1,12 @@ +export function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..5ed6cb9 --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,168 @@ +import * as React from "react"; +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; +import { cva } from "class-variance-authority"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function NavigationMenu({ + className, + children, + viewport = true, + ...props +}: React.ComponentProps & { + viewport?: boolean; +}) { + return ( + + {children} + {viewport && } + + ); +} + +function NavigationMenuList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function NavigationMenuItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" +); + +function NavigationMenuTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children}{" "} + + ); +} + +function NavigationMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function NavigationMenuViewport({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ); +} + +function NavigationMenuLink({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function NavigationMenuIndicator({ + className, + ...props +}: React.ComponentProps) { + return ( + +
+ + ); +} + +export { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, + navigationMenuTriggerStyle, +}; diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..6d6efec --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/docs/TODO.md b/docs/TODO.md index 77e5b65..0d656e1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -100,7 +100,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks ### Layout Components -- [ ] Create Header component with navigation - [#53](https://github.com/nullcoder/ghostpaste/issues/53) +- [x] Create Header component with navigation - [#53](https://github.com/nullcoder/ghostpaste/issues/53) - [ ] Create Footer component - [#70](https://github.com/nullcoder/ghostpaste/issues/70) - [ ] Create Container component for consistent spacing - [#62](https://github.com/nullcoder/ghostpaste/issues/62) - [x] Implement responsive design tokens - [#57](https://github.com/nullcoder/ghostpaste/issues/57) diff --git a/package-lock.json b/package-lock.json index b3c8013..8e5375b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", @@ -10814,6 +10816,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -10937,6 +10975,42 @@ } } }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", + "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -13650,7 +13724,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -13660,7 +13734,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -15365,7 +15439,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { diff --git a/package.json b/package.json index f9f5072..8cacc0d 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12",