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