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 fea0afdec0
commit 85712c15eb
2 changed files with 189 additions and 2 deletions

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>