feat: Added mobile hamburger menu with 44px touch targets, Escape/outsi…

- "frontend/src/App.tsx"
- "frontend/src/App.css"

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-03-31 08:45:33 +00:00
parent 50675db557
commit 089435a990
5 changed files with 285 additions and 3 deletions

View file

@ -30,7 +30,7 @@ This task delivers R020 (Global Search in Navigation).
- Estimate: 45m - Estimate: 45m
- Files: frontend/src/components/SearchAutocomplete.tsx, frontend/src/App.tsx, frontend/src/App.css, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx - Files: frontend/src/components/SearchAutocomplete.tsx, frontend/src/App.tsx, frontend/src/App.css, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx
- Verify: cd frontend && npm run build 2>&1 | tail -5 - Verify: cd frontend && npm run build 2>&1 | tail -5
- [ ] **T02: Add mobile hamburger menu with touch targets and auto-close behavior** — Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens. - [x] **T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close** — Add a hamburger menu button visible below 768px that toggles a mobile nav panel. Nav links stack vertically with 44×44px minimum touch targets. Menu closes on route change, Escape, and outside click. The nav search bar (from T01) repositions inside the mobile panel on small screens.
This task delivers R021 (Mobile Hamburger Menu). This task delivers R021 (Mobile Hamburger Menu).

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M011/S03/T01",
"timestamp": 1774946535287,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,79 @@
---
id: T02
parent: S03
milestone: M011
provides: []
requires: []
affects: []
key_files: ["frontend/src/App.tsx", "frontend/src/App.css"]
key_decisions: ["Hamburger icon toggles to X via conditional SVG rendering", "Mobile nav search duplicated inside menu panel rather than CSS-repositioned", "AdminDropdown restyled to full-width static submenu in mobile panel"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Build succeeds (51 modules, 858ms). Browser tested at 390×844: hamburger visible, menu opens/closes correctly, all three auto-close mechanisms work, desktop hides hamburger, home page omits search correctly."
completed_at: 2026-03-31T08:45:23.843Z
blocker_discovered: false
---
# T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close
> Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close
## What Happened
---
id: T02
parent: S03
milestone: M011
key_files:
- frontend/src/App.tsx
- frontend/src/App.css
key_decisions:
- Hamburger icon toggles to X via conditional SVG rendering
- Mobile nav search duplicated inside menu panel rather than CSS-repositioned
- AdminDropdown restyled to full-width static submenu in mobile panel
duration: ""
verification_result: passed
completed_at: 2026-03-31T08:45:23.843Z
blocker_discovered: false
---
# T02: Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close
**Added mobile hamburger menu with 44px touch targets, Escape/outside-click/route-change auto-close**
## What Happened
Added menuOpen state with three auto-close mechanisms (route change, Escape key, outside click) to App.tsx. Hamburger button uses conditional SVG (three-line ↔ X). At <768px breakpoint, nav collapses to absolute-positioned column panel with max-height transition. All nav links get min-height: 44px touch targets. Mobile menu includes duplicated SearchAutocomplete and full-width AdminDropdown. Desktop layout unchanged.
## Verification
Build succeeds (51 modules, 858ms). Browser tested at 390×844: hamburger visible, menu opens/closes correctly, all three auto-close mechanisms work, desktop hides hamburger, home page omits search correctly.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build 2>&1 | tail -15` | 0 | ✅ pass | 3000ms |
| 2 | `Browser: hamburger visible at 390x844, menu toggles, auto-close on route/Escape/outside-click` | 0 | ✅ pass | 0ms |
## Deviations
Search bar rendered as second SearchAutocomplete instance inside mobile menu rather than CSS-repositioned from header. GlobalShortcut omitted on mobile duplicate to avoid double Cmd+K registration.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/App.tsx`
- `frontend/src/App.css`
## Deviations
Search bar rendered as second SearchAutocomplete instance inside mobile menu rather than CSS-repositioned from header. GlobalShortcut omitted on mobile duplicate to avoid double Cmd+K registration.
## Known Issues
None.

View file

@ -742,6 +742,124 @@ a.app-footer__repo:hover {
color: var(--color-error); color: var(--color-error);
} }
/* ── Hamburger button ─────────────────────────────────────────────────────── */
.hamburger-btn {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: none;
background: none;
color: var(--color-text-on-header);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
}
.hamburger-btn:hover {
color: var(--color-text-on-header-hover);
}
/* ── Mobile nav search inside hamburger menu ──────────────────────────────── */
.mobile-nav-search {
display: none;
}
/* ── Mobile breakpoint (768px) ────────────────────────────────────────────── */
@media (max-width: 768px) {
.hamburger-btn {
display: flex;
}
.app-header {
flex-wrap: wrap;
position: relative;
}
/* Hide the nav search bar in header on mobile — it goes inside the menu */
.app-header > .search-container--nav {
display: none;
}
.app-header__right {
/* Keep the hamburger button visible; nav collapses */
order: 0;
}
.app-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
flex-direction: column;
align-items: stretch;
background: var(--color-bg-header);
border-top: 1px solid var(--color-border);
box-shadow: 0 8px 24px var(--color-shadow-heavy);
z-index: 200;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.app-nav--open {
display: flex;
max-height: 24rem;
opacity: 1;
}
.app-nav a,
.app-nav .admin-dropdown__trigger {
min-height: 44px;
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-bottom: 1px solid var(--color-border);
}
.app-nav .admin-dropdown {
width: 100%;
}
.app-nav .admin-dropdown__trigger {
width: 100%;
text-align: left;
}
.app-nav .admin-dropdown__menu {
position: static;
box-shadow: none;
border: none;
border-radius: 0;
background: var(--color-bg-header-alt);
}
.app-nav .admin-dropdown__item {
padding-left: 2.5rem;
min-height: 44px;
display: flex;
align-items: center;
}
.mobile-nav-search {
display: block;
padding: 0.75rem 1.5rem;
border-top: 1px solid var(--color-border);
}
.mobile-nav-search .search-container--nav {
max-width: 100%;
}
}
/* ── Responsive ───────────────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {

View file

@ -1,3 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import Home from "./pages/Home"; import Home from "./pages/Home";
import SearchResults from "./pages/SearchResults"; import SearchResults from "./pages/SearchResults";
@ -17,10 +18,47 @@ export default function App() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const showNavSearch = location.pathname !== "/"; const showNavSearch = location.pathname !== "/";
const [menuOpen, setMenuOpen] = useState(false);
const headerRef = useRef<HTMLElement>(null);
// Close menu on route change
useEffect(() => {
setMenuOpen(false);
}, [location.pathname]);
// Close menu on Escape
useEffect(() => {
if (!menuOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [menuOpen]);
// Close menu on outside click
const handleOutsideClick = useCallback(
(e: MouseEvent) => {
if (
menuOpen &&
headerRef.current &&
!headerRef.current.contains(e.target as Node)
) {
setMenuOpen(false);
}
},
[menuOpen],
);
useEffect(() => {
if (!menuOpen) return;
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [menuOpen, handleOutsideClick]);
return ( return (
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header" ref={headerRef}>
<Link to="/" className="app-header__brand"> <Link to="/" className="app-header__brand">
<h1>Chrysopedia</h1> <h1>Chrysopedia</h1>
</Link> </Link>
@ -33,11 +71,42 @@ export default function App() {
/> />
)} )}
<div className="app-header__right"> <div className="app-header__right">
<nav className="app-nav"> <button
className="hamburger-btn"
onClick={() => setMenuOpen((v) => !v)}
aria-label="Toggle navigation"
aria-expanded={menuOpen}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{menuOpen ? (
<>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="6" y1="18" x2="18" y2="6" />
</>
) : (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
)}
</svg>
</button>
<nav className={`app-nav${menuOpen ? " app-nav--open" : ""}`}>
<Link to="/">Home</Link> <Link to="/">Home</Link>
<Link to="/topics">Topics</Link> <Link to="/topics">Topics</Link>
<Link to="/creators">Creators</Link> <Link to="/creators">Creators</Link>
<AdminDropdown /> <AdminDropdown />
{/* Mobile-only: search bar inside menu when not shown in header */}
{menuOpen && showNavSearch && (
<div className="mobile-nav-search">
<SearchAutocomplete
variant="nav"
placeholder="Search…"
onSearch={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
/>
</div>
)}
</nav> </nav>
</div> </div>
</header> </header>