From 0ab88a2034be7f5ac19e51b51bbc80d329fdf1cd Mon Sep 17 00:00:00 2001 From: Alex Kwiatkowski Date: Sat, 27 Dec 2025 13:53:23 -0800 Subject: [PATCH 1/2] docs: add mobile navigation design plan (10) --- docs/plans/10-mobile-navigation/design.md | 157 ++++++++++++++++++ .../10-mobile-navigation/implementation.md | 104 ++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 docs/plans/10-mobile-navigation/design.md create mode 100644 docs/plans/10-mobile-navigation/implementation.md diff --git a/docs/plans/10-mobile-navigation/design.md b/docs/plans/10-mobile-navigation/design.md new file mode 100644 index 0000000..60ef0ed --- /dev/null +++ b/docs/plans/10-mobile-navigation/design.md @@ -0,0 +1,157 @@ +# Mobile Navigation Design + +## Problem + +The current navbar uses a horizontal layout with `justify-between` and fixed `gap-8` spacing. With the addition of more nav links (7+), the mobile experience becomes cramped and unusable. + +## Solution + +Implement a hamburger menu pattern on mobile with a full-screen overlay. + +## Design Decisions + +### Breakpoint Strategy + +| Viewport | Behavior | +|----------|----------| +| < 768px (below `md`) | Hamburger menu | +| ≥ 768px (`md` and up) | Full horizontal nav | + +### Mobile Header Layout + +``` +[Logo] [Let's Talk] [☰] +``` + +- Logo on the left +- "Let's Talk" CTA visible outside menu (primary conversion action) +- Hamburger icon on the right + +### Desktop Header Layout + +``` +[Logo] [Products] [Services] [About] [Blog] ... [Let's Talk] +``` + +### Mobile Menu Style + +**Full-screen overlay** chosen over slide-out drawer or dropdown: + +- With 7+ links, generous spacing is needed +- Creates focus by hiding page content +- Simpler to implement +- Matches bold brand aesthetic + +**Visual structure:** +``` +┌─────────────────────────────┐ +│ [Logo] [✕] │ ← Header persists, X closes +├─────────────────────────────┤ +│ │ +│ Products │ +│ Services │ +│ About │ +│ Blog │ +│ Careers │ +│ Case Studies │ +│ │ +└─────────────────────────────┘ +``` + +### Visual Styling + +**Menu background:** `bg-background` (same dark background as site) + +**Nav link styling in menu:** +- Large tappable text (`text-2xl` or `text-3xl`) +- Generous vertical spacing (`py-4` per link) +- Centered alignment +- Uppercase + tracking-wide (consistent with desktop) +- Hover/active: `text-accent` color shift + +**Hamburger icon:** +- Three horizontal lines (classic ☰) +- Transforms to X when open (animated) +- Size: 24x24px with 44x44px minimum tap target + +### Animation + +**Opening:** +1. Overlay fades in (150ms) +2. Links stagger in from top (50ms delay each) + +**Closing:** +1. Quick fade out (100ms) +2. No stagger (feels snappier) + +### Behavior + +**Close triggers:** +- X button click +- Nav link click +- Escape key press +- Route change (automatic) + +**Scroll lock:** Body scroll disabled when menu is open + +### Accessibility + +- `aria-expanded` on hamburger button +- `aria-hidden` on menu when closed +- Focus moves to first link when menu opens +- Focus returns to hamburger when menu closes +- Escape key closes menu +- Focus trapped inside menu while open + +## Component Structure + +``` +src/components/navigation/ +├── Navbar.tsx # Updated with mobile logic +├── MobileMenuButton.tsx # Animated hamburger/X icon +├── MobileMenu.tsx # Full-screen overlay +├── NavLink.tsx # Existing +└── navLinks.ts # Shared link configuration +``` + +**Component responsibilities:** + +| Component | Responsibility | +|-----------|----------------| +| `Navbar` | Layout, state management, breakpoint logic | +| `MobileMenuButton` | Hamburger/X icon with CSS animation | +| `MobileMenu` | Full-screen overlay with links | +| `navLinks.ts` | Shared link data for desktop & mobile | + +## State Management + +Simple React `useState`: + +```tsx +const [isMenuOpen, setIsMenuOpen] = useState(false) +``` + +Scroll lock via `useEffect`: + +```tsx +useEffect(() => { + document.body.style.overflow = isMenuOpen ? 'hidden' : '' + return () => { document.body.style.overflow = '' } +}, [isMenuOpen]) +``` + +## Edge Cases + +| Case | Handling | +|------|----------| +| Long link text | Centered + vertical stack, length doesn't matter | +| Many links (10+) | Menu scrolls if content exceeds viewport | +| Fast open/close | CSS transitions handle interrupts gracefully | +| Resize while open | Menu closes when viewport crosses `md` breakpoint | + +## Not Included (YAGNI) + +- Nested submenus / dropdowns +- Search in mobile menu +- Social links in menu +- Backdrop blur (solid background is simpler) diff --git a/docs/plans/10-mobile-navigation/implementation.md b/docs/plans/10-mobile-navigation/implementation.md new file mode 100644 index 0000000..f614f05 --- /dev/null +++ b/docs/plans/10-mobile-navigation/implementation.md @@ -0,0 +1,104 @@ +# Mobile Navigation Implementation Plan + +## Overview + +Implement hamburger menu for mobile navigation per design.md. + +## Prerequisites + +- Read existing `Navbar.tsx` implementation +- Understand current nav link structure + +## Tasks + +### 1. Create shared nav links configuration + +**File:** `src/components/navigation/navLinks.ts` + +```typescript +export const navLinks = [ + { href: '/products', label: 'Products' }, + { href: '/services', label: 'Services' }, + // ... additional links +] as const +``` + +This centralizes link data for both desktop and mobile navigation. + +### 2. Create MobileMenuButton component + +**File:** `src/components/navigation/MobileMenuButton.tsx` + +- Animated hamburger icon that transforms to X +- Uses CSS transitions for smooth animation +- Props: `isOpen`, `onClick` +- Includes proper `aria-expanded` and `aria-label` + +### 3. Create MobileMenu component + +**File:** `src/components/navigation/MobileMenu.tsx` + +- Full-screen overlay with `fixed inset-0` +- Renders nav links from shared config +- Handles: + - Escape key to close + - Click on link to close + - Focus trap + - Scroll lock +- Stagger animation for links on open + +### 4. Update Navbar component + +**File:** `src/components/navigation/Navbar.tsx` + +- Add `isMenuOpen` state +- Conditionally render desktop nav (hidden on mobile) +- Conditionally render MobileMenuButton (hidden on desktop) +- Render MobileMenu when open +- Close menu on route change + +### 5. Add close-on-resize behavior + +In Navbar, add effect to close menu when viewport crosses `md` breakpoint: + +```typescript +useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 768px)') + const handleChange = () => { + if (mediaQuery.matches) setIsMenuOpen(false) + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) +}, []) +``` + +### 6. Write tests + +- Unit tests for MobileMenuButton (renders, toggles aria-expanded) +- Unit tests for MobileMenu (renders links, calls onClose) +- E2E test for mobile menu flow (open, navigate, close) + +### 7. Visual review + +- Test in Ladle at various viewport sizes +- Verify animation timing feels right +- Check focus states and keyboard navigation + +## File Checklist + +- [ ] `src/components/navigation/navLinks.ts` +- [ ] `src/components/navigation/MobileMenuButton.tsx` +- [ ] `src/components/navigation/MobileMenuButton.test.tsx` +- [ ] `src/components/navigation/MobileMenu.tsx` +- [ ] `src/components/navigation/MobileMenu.test.tsx` +- [ ] `src/components/navigation/Navbar.tsx` (update) +- [ ] `e2e/mobile-navigation.spec.ts` + +## Verification + +1. `pnpm typecheck` - no type errors +2. `pnpm lint` - no lint errors +3. `pnpm test` - all tests pass +4. `pnpm e2e` - E2E tests pass +5. `just ladle` - visual review +6. Manual test on actual mobile device or emulator From de5ddb2736b3c70b96d26f68d8e5ef254d41fdfe Mon Sep 17 00:00:00 2001 From: Alex Kwiatkowski Date: Sat, 27 Dec 2025 14:03:36 -0800 Subject: [PATCH 2/2] feat: add hamburger menu for mobile navigation - Create MobileMenuButton with animated hamburger-to-X transition - Create MobileMenu full-screen overlay with stagger animation - Extract shared navLinks configuration for consistency - Update Navbar to show hamburger on mobile, full nav on desktop - Keep "Let's Talk" CTA visible outside menu on mobile - Add E2E tests for mobile navigation flows - Update existing tests for new responsive behavior --- e2e/mobile-navigation.spec.ts | 99 +++++++++++++++ e2e/products.spec.ts | 5 +- src/components/navigation/MobileMenu.test.tsx | 63 ++++++++++ src/components/navigation/MobileMenu.tsx | 69 +++++++++++ .../navigation/MobileMenuButton.test.tsx | 34 ++++++ .../navigation/MobileMenuButton.tsx | 44 +++++++ src/components/navigation/Navbar.tsx | 113 ++++++++++++------ src/components/navigation/index.ts | 1 + src/components/navigation/navLinks.ts | 14 +++ 9 files changed, 407 insertions(+), 35 deletions(-) create mode 100644 e2e/mobile-navigation.spec.ts create mode 100644 src/components/navigation/MobileMenu.test.tsx create mode 100644 src/components/navigation/MobileMenu.tsx create mode 100644 src/components/navigation/MobileMenuButton.test.tsx create mode 100644 src/components/navigation/MobileMenuButton.tsx create mode 100644 src/components/navigation/navLinks.ts diff --git a/e2e/mobile-navigation.spec.ts b/e2e/mobile-navigation.spec.ts new file mode 100644 index 0000000..75f0c1e --- /dev/null +++ b/e2e/mobile-navigation.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test' + +test.describe('Mobile Navigation', () => { + test.use({ viewport: { width: 375, height: 667 } }) // iPhone SE size + + test('shows hamburger menu on mobile', async ({ page }) => { + await page.goto('/') + + // Hamburger button should be visible + const menuButton = page.getByRole('button', { name: /open menu/i }) + await expect(menuButton).toBeVisible() + + // Desktop nav links should be hidden + const desktopNav = page.locator('.md\\:flex').filter({ hasText: 'Products' }) + await expect(desktopNav).toBeHidden() + }) + + test('opens mobile menu when hamburger is clicked', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }) + + const menuButton = page.getByRole('button', { name: /open menu/i }) + await expect(menuButton).toBeVisible() + await menuButton.click() + + // Mobile menu should be visible with nav links + const mobileNav = page.getByRole('navigation', { name: /mobile navigation/i }) + await expect(mobileNav).toBeVisible() + await expect(mobileNav.getByRole('link', { name: 'Products' })).toBeVisible() + await expect(mobileNav.getByRole('link', { name: 'Services' })).toBeVisible() + + // Button should now say close + await expect(page.getByRole('button', { name: /close menu/i })).toBeVisible() + }) + + test('closes mobile menu when X is clicked', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }) + + // Open menu + await page.getByRole('button', { name: /open menu/i }).click() + await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeVisible() + + // Close menu + await page.getByRole('button', { name: /close menu/i }).click() + await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden() + }) + + test('closes mobile menu when nav link is clicked', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }) + + // Open menu + await page.getByRole('button', { name: /open menu/i }).click() + const mobileNav = page.getByRole('navigation', { name: /mobile navigation/i }) + await expect(mobileNav).toBeVisible() + + // Click a nav link + await mobileNav.getByRole('link', { name: 'Products' }).click() + + // Should navigate and close menu + await expect(page).toHaveURL('/products') + await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden() + }) + + test("Let's Talk button is visible outside menu on mobile", async ({ page }) => { + await page.goto('/') + + // CTA should be visible in header without opening menu + const header = page.locator('header') + await expect(header.getByRole('link', { name: /let's talk/i })).toBeVisible() + }) + + test('closes mobile menu when Escape is pressed', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }) + + // Open menu + await page.getByRole('button', { name: /open menu/i }).click() + await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeVisible() + + // Press Escape + await page.keyboard.press('Escape') + await expect(page.getByRole('navigation', { name: /mobile navigation/i })).toBeHidden() + }) +}) + +test.describe('Desktop Navigation', () => { + test.use({ viewport: { width: 1280, height: 720 } }) + + test('shows full navigation on desktop', async ({ page }) => { + await page.goto('/') + + // Desktop nav links should be visible + const header = page.locator('header') + await expect(header.getByRole('link', { name: 'Products' })).toBeVisible() + await expect(header.getByRole('link', { name: 'Services' })).toBeVisible() + await expect(header.getByRole('link', { name: /let's talk/i })).toBeVisible() + + // Hamburger button should be hidden + await expect(page.getByRole('button', { name: /open menu/i })).toBeHidden() + }) +}) diff --git a/e2e/products.spec.ts b/e2e/products.spec.ts index 1d921e9..a15cb9b 100644 --- a/e2e/products.spec.ts +++ b/e2e/products.spec.ts @@ -49,9 +49,12 @@ test.describe('Products Pages', () => { }) test('navbar has Products link', async ({ page }) => { + // Set desktop viewport - on mobile, Products is in hamburger menu + await page.setViewportSize({ width: 1280, height: 720 }) await page.goto('/') - const productsLink = page.getByRole('link', { name: 'Products', exact: true }) + const header = page.locator('header') + const productsLink = header.getByRole('link', { name: 'Products', exact: true }) await expect(productsLink).toBeVisible() await productsLink.click() await expect(page).toHaveURL('/products') diff --git a/src/components/navigation/MobileMenu.test.tsx b/src/components/navigation/MobileMenu.test.tsx new file mode 100644 index 0000000..9bee36e --- /dev/null +++ b/src/components/navigation/MobileMenu.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { MobileMenu } from './MobileMenu' + +const mockLinks = [ + { href: '/products', label: 'Products' }, + { href: '/services', label: 'Services' }, + { href: '/about', label: 'About' }, +] + +// Mock TanStack Router Link +vi.mock('@tanstack/react-router', () => ({ + Link: ({ + children, + to, + onClick, + }: { children: React.ReactNode; to: string; onClick?: () => void }) => ( + + {children} + + ), +})) + +describe('MobileMenu', () => { + it('renders all nav links', () => { + render() + + expect(screen.getByRole('link', { name: 'Products' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Services' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument() + }) + + it('is hidden when not open', () => { + render() + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('calls onClose when a link is clicked', async () => { + const user = userEvent.setup() + const handleClose = vi.fn() + render() + + await user.click(screen.getByRole('link', { name: 'Products' })) + expect(handleClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when Escape key is pressed', async () => { + const user = userEvent.setup() + const handleClose = vi.fn() + render() + + await user.keyboard('{Escape}') + expect(handleClose).toHaveBeenCalledTimes(1) + }) + + it('has proper aria attributes', () => { + render() + + const nav = screen.getByRole('navigation') + expect(nav).toHaveAttribute('aria-label', 'Mobile navigation') + }) +}) diff --git a/src/components/navigation/MobileMenu.tsx b/src/components/navigation/MobileMenu.tsx new file mode 100644 index 0000000..29df4f8 --- /dev/null +++ b/src/components/navigation/MobileMenu.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/cn' +import { Link } from '@tanstack/react-router' +import { useEffect } from 'react' +import type { NavLinkItem } from './navLinks' + +interface MobileMenuProps { + isOpen: boolean + onClose: () => void + links: NavLinkItem[] + className?: string +} + +export function MobileMenu({ isOpen, onClose, links, className }: MobileMenuProps) { + // Handle Escape key + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + // Lock body scroll when open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + if (!isOpen) return null + + return ( + + ) +} diff --git a/src/components/navigation/MobileMenuButton.test.tsx b/src/components/navigation/MobileMenuButton.test.tsx new file mode 100644 index 0000000..d4b0b8c --- /dev/null +++ b/src/components/navigation/MobileMenuButton.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { MobileMenuButton } from './MobileMenuButton' + +describe('MobileMenuButton', () => { + it('renders a button with menu label when closed', () => { + render() + const button = screen.getByRole('button', { name: /open menu/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveAttribute('aria-expanded', 'false') + }) + + it('renders a button with close label when open', () => { + render() + const button = screen.getByRole('button', { name: /close menu/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('calls onClick when clicked', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + await user.click(screen.getByRole('button')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('applies custom className', () => { + render() + expect(screen.getByRole('button')).toHaveClass('custom-class') + }) +}) diff --git a/src/components/navigation/MobileMenuButton.tsx b/src/components/navigation/MobileMenuButton.tsx new file mode 100644 index 0000000..98ea7f1 --- /dev/null +++ b/src/components/navigation/MobileMenuButton.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/cn' +import type { ComponentProps } from 'react' + +interface MobileMenuButtonProps extends Omit, 'children'> { + isOpen: boolean + onClick: () => void +} + +export function MobileMenuButton({ isOpen, onClick, className, ...props }: MobileMenuButtonProps) { + return ( + + ) +} diff --git a/src/components/navigation/Navbar.tsx b/src/components/navigation/Navbar.tsx index c92e9cd..467c184 100644 --- a/src/components/navigation/Navbar.tsx +++ b/src/components/navigation/Navbar.tsx @@ -1,46 +1,91 @@ import { Container, Logo } from '@/components/ui' import { cn } from '@/lib/cn' -import { Link } from '@tanstack/react-router' +import { Link, useRouter } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { MobileMenu } from './MobileMenu' +import { MobileMenuButton } from './MobileMenuButton' +import { ctaLink, navLinks } from './navLinks' interface NavbarProps { className?: string } export function Navbar({ className }: NavbarProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false) + const router = useRouter() + + // Close menu on route change + useEffect(() => { + return router.subscribe('onBeforeNavigate', () => { + setIsMenuOpen(false) + }) + }, [router]) + + // Close menu when viewport crosses md breakpoint + useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 768px)') + const handleChange = () => { + if (mediaQuery.matches) { + setIsMenuOpen(false) + } + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + return ( -
- - - -
+ + {/* Desktop Navigation */} +
+ {navLinks.map((link) => ( + + {link.label} + + ))} + + {ctaLink.label} + +
+ + {/* Mobile Navigation Controls */} +
+ + {ctaLink.label} + + setIsMenuOpen((prev) => !prev)} + /> +
+ + + + + {/* Mobile Menu Overlay */} + setIsMenuOpen(false)} links={navLinks} /> + ) } diff --git a/src/components/navigation/index.ts b/src/components/navigation/index.ts index 0a0c674..acb235b 100644 --- a/src/components/navigation/index.ts +++ b/src/components/navigation/index.ts @@ -1,3 +1,4 @@ export { NavLink } from './NavLink' export { Navbar } from './Navbar' export { Footer } from './Footer' +export { navLinks, ctaLink, type NavLinkItem } from './navLinks' diff --git a/src/components/navigation/navLinks.ts b/src/components/navigation/navLinks.ts new file mode 100644 index 0000000..617ff2a --- /dev/null +++ b/src/components/navigation/navLinks.ts @@ -0,0 +1,14 @@ +export interface NavLinkItem { + href: string + label: string +} + +export const navLinks: NavLinkItem[] = [ + { href: '/products', label: 'Products' }, + { href: '/services', label: 'Services' }, +] + +export const ctaLink: NavLinkItem = { + href: '/contact', + label: "Let's Talk", +}