diff --git a/web/components/bureau-nav.tsx b/web/components/bureau-nav.tsx index dc09733..f3f9415 100644 --- a/web/components/bureau-nav.tsx +++ b/web/components/bureau-nav.tsx @@ -14,6 +14,7 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import { AuthBar } from "./auth-bar"; import { LocaleToggle, type Locale } from "./locale-toggle"; +import { MobileNav } from "./mobile-nav"; export interface Crumb { /** Display label. */ @@ -29,6 +30,18 @@ const COPY = { export function BureauNav({ crumbs, locale = "pt-br" }: { crumbs: Crumb[]; locale?: Locale }) { const t = COPY[locale]; + // Same item list as the SiteHeader on the home — the mobile drawer carries + // it so a reader on a case file can still jump to /sightings or /witnesses. + const navItems = [ + { href: "/sightings", label: locale === "en" ? "Sightings" : "Avistamentos" }, + { href: "/witnesses", label: locale === "en" ? "Witnesses" : "Testemunhas" }, + { href: "/objects", label: locale === "en" ? "Craft" : "Objetos" }, + { href: "/locations", label: locale === "en" ? "Locations" : "Locais" }, + { href: "/operations", label: locale === "en" ? "Programs" : "Programas" }, + { href: "/bureau", label: locale === "en" ? "Case files" : "Casos" }, + { href: "/search", label: locale === "en" ? "Search" : "Busca" }, + ]; + return ( )} - + {/* Desktop right cluster — toggle + auth. Hidden on mobile; the + hamburger drawer carries the same controls there. */} + + {/* Mobile menu — hamburger that opens the full primary nav drawer. */} + + + ); diff --git a/web/components/mobile-nav.tsx b/web/components/mobile-nav.tsx new file mode 100644 index 0000000..bc573ae --- /dev/null +++ b/web/components/mobile-nav.tsx @@ -0,0 +1,145 @@ +"use client"; + +/** + * MobileNav — hamburger button + slide-in drawer for narrow viewports. + * + * The desktop SiteHeader holds the full primary nav inline; on mobile that + * cluster overflows past the viewport edge. This drawer keeps the brand + * always visible and tucks the rest behind a hamburger. + * + * Behaviour: + * - Opens on hamburger click, closes on backdrop click, ESC, link click, + * or close button. + * - Locks body scroll while open so the page underneath doesn't jiggle. + * - Marks the active nav link via usePathname (mirrors NavLink). + * - aria-modal="true" + dialog role + escape handling for screen readers. + * + * The locale toggle and auth pill live inside the panel so the mobile + * surface offers every action the desktop navbar does. + */ +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { Menu, X } from "lucide-react"; +import { AuthBar } from "./auth-bar"; +import { LocaleToggleClient } from "./locale-toggle-client"; + +interface NavItem { + href: string; + label: string; +} + +const COPY = { + "pt-br": { open: "Abrir menu", close: "Fechar menu", section: "Navegação" }, + en: { open: "Open menu", close: "Close menu", section: "Navigation" }, +} as const; + +export function MobileNav({ + locale, + items, +}: { locale: "pt-br" | "en"; items: NavItem[] }) { + const [open, setOpen] = useState(false); + const pathname = usePathname() || "/"; + const closeBtnRef = useRef(null); + const t = COPY[locale]; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + window.addEventListener("keydown", onKey); + // Lock body scroll while the panel is open. + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + // Move focus to the close button so VoiceOver / TalkBack announce the + // dialog and keyboard users can dismiss it with one press. + closeBtnRef.current?.focus(); + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open]); + + function isActive(href: string): boolean { + return pathname === href || (href !== "/" && pathname.startsWith(`${href}/`)); + } + + return ( + <> + setOpen(true)} + aria-label={t.open} + aria-expanded={open} + aria-controls="mobile-nav-panel" + className="inline-flex items-center justify-center w-10 h-10 rounded-md text-[#e7ecf3] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)] transition-colors" + > + + + + {open && ( + + {/* Backdrop */} + setOpen(false)} + className="absolute inset-0 bg-[#0a0e1a]/80 backdrop-blur-sm" + /> + {/* Panel */} + + + + ▍ + The Disclosure Bureau + + setOpen(false)} + aria-label={t.close} + className="inline-flex items-center justify-center w-10 h-10 rounded-md text-[#cbd2dd] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)] transition-colors" + > + + + + + + + {items.map((it) => { + const active = isActive(it.href); + return ( + + setOpen(false)} + aria-current={active ? "page" : undefined} + className={ + active + ? "block px-3 py-3 rounded-md text-[15px] font-mono text-[#e0c080] bg-[rgba(224,192,128,0.10)] border-l-2 border-[#e0c080]" + : "block px-3 py-3 rounded-md text-[15px] font-mono text-[#e7ecf3] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.06)] border-l-2 border-transparent transition-colors" + } + > + {it.label} + + + ); + })} + + + + + + + + + + )} + > + ); +} diff --git a/web/components/site-header.tsx b/web/components/site-header.tsx index 219fb91..19cb235 100644 --- a/web/components/site-header.tsx +++ b/web/components/site-header.tsx @@ -2,17 +2,30 @@ * SiteHeader — top navigation present on the homepage. * * Magazine-style — clean, with the brand mark left and a slim nav right. + * On narrow viewports the nav cluster collapses behind a hamburger drawer + * (MobileNav) so the row stops overflowing the screen edge. * Sub-pages use BureauNav (sticky with breadcrumbs) instead. */ import Link from "next/link"; import { AuthBar } from "./auth-bar"; import { LocaleToggle } from "./locale-toggle"; +import { MobileNav } from "./mobile-nav"; import { NavLink } from "./nav-link"; export function SiteHeader({ locale }: { locale: "pt-br" | "en" }) { + const items = [ + { href: "/sightings", label: locale === "en" ? "Sightings" : "Avistamentos" }, + { href: "/witnesses", label: locale === "en" ? "Witnesses" : "Testemunhas" }, + { href: "/objects", label: locale === "en" ? "Craft" : "Objetos" }, + { href: "/locations", label: locale === "en" ? "Locations" : "Locais" }, + { href: "/operations", label: locale === "en" ? "Programs" : "Programas" }, + { href: "/bureau", label: locale === "en" ? "Case files" : "Casos" }, + { href: "/search", label: locale === "en" ? "Search" : "Busca" }, + ]; + return ( - + ▍ @@ -20,20 +33,22 @@ export function SiteHeader({ locale }: { locale: "pt-br" | "en" }) { + {/* Desktop nav — hidden below md, shown md+. */} - - - - - - - + {items.map((it) => ( + + ))} + + {/* Mobile menu — hamburger + drawer; hidden md+. */} + + + );