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:
parent
50675db557
commit
089435a990
5 changed files with 285 additions and 3 deletions
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
16
.gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M011/slices/S03/tasks/T01-VERIFY.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
79
.gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md
Normal file
79
.gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md
Normal 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.
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue