From 067ce7bee610f48b78321f9920a22c0ef53aef98 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Fri, 29 May 2026 01:09:45 -0300 Subject: [PATCH] ux(nav): mobile hamburger + slide-in drawer for narrow viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile audit caught the primary nav overflowing past the viewport edge ("Programs" was clipped on the right at 390px), and sub-pages on mobile had no access to the entity hub at all because BureauNav doesn't carry it. - New MobileNav (client) — hamburger button that opens a right-side slide-in panel containing the full primary nav, locale toggle and auth pill. ESC, backdrop click, link click, and the close button all dismiss; body scroll locks while open; focus jumps to the close button so screen readers announce the dialog and keyboard users can dismiss in one keystroke. - SiteHeader — hide desktop nav --- web/components/bureau-nav.tsx | 21 ++++- web/components/mobile-nav.tsx | 145 +++++++++++++++++++++++++++++++++ web/components/site-header.tsx | 33 ++++++-- 3 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 web/components/mobile-nav.tsx 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 ( ); 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 ( + <> + + + {open && ( + + + + +
+ + +
+ + + )} + + ); +} 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+. */} + + {/* Mobile menu — hamburger + drawer; hidden md+. */} +
+ +
);