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);
|
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