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
|
||||
- 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
|
||||
- [ ] **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).
|
||||
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
||||
/* ── 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 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@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 Home from "./pages/Home";
|
||||
import SearchResults from "./pages/SearchResults";
|
||||
|
|
@ -17,10 +18,47 @@ export default function App() {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<header className="app-header" ref={headerRef}>
|
||||
<Link to="/" className="app-header__brand">
|
||||
<h1>Chrysopedia</h1>
|
||||
</Link>
|
||||
|
|
@ -33,11 +71,42 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
<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="/topics">Topics</Link>
|
||||
<Link to="/creators">Creators</Link>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue