feat(nav): replace squeezed mobile tab bar with a header menu
The mobile bottom bar rendered all 15 routes into a single row. On a 390px iPhone that works out at ~17px per tab: the icons overlap, every label truncates to a single letter and the tap targets sit far below the 44px minimum, so the tabs could not be read or hit reliably. Mobile now uses a hamburger in the header that opens a full-screen menu listing every section, grouped (Main / CIPC & BizPortal / SARS / Practice). The panel locks background scroll, closes on Escape or on navigation, and carries the theme toggle so the mobile chrome keeps parity with the desktop cluster. Desktop had the same problem in a different shape. The full 15-link row needed 1752px inside a 1280px container, so the page scrolled sideways even at 1920px (measured scrollWidth 2065 vs a 1920 viewport) and the right-hand cluster was pushed off screen. The inline nav now carries six primary links plus a "More" dropdown, which fits exactly. - NavBar: mobile tab bar replaced by the header menu panel; desktop nav reduced to primary links + More dropdown; grouped list shared by both so the two surfaces cannot drift apart. - RouteChrome: dropped the pb-24 md:pb-0 padding that only existed to clear the old bottom bar. - .gitignore / Workflow/.dockerignore: ignore .next-stale* so a stale build directory cannot bloat the Docker build context.
This commit is contained in:
parent
2f01b55d5a
commit
3595cd54bb
4 changed files with 330 additions and 71 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -9,6 +9,7 @@
|
|||
# ----------------------------------------------- deps, build & tooling ---
|
||||
node_modules/
|
||||
.next/
|
||||
.next-stale*/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
|
|
@ -29,6 +30,14 @@ npm-debug.log*
|
|||
scratch-*/
|
||||
_backups/
|
||||
|
||||
# --------------------------- CIPC report run state (generated) --------
|
||||
# The report explorer writes its run folder, produced PDFs and sample
|
||||
# page renders at runtime — none of that belongs in the repo.
|
||||
Workflow/.cipc-report/
|
||||
Workflow/reports/
|
||||
Workflow/scripts/one-id.txt
|
||||
Workflow/scripts/cipc-report/_sample*/
|
||||
|
||||
# --------------------------------- debug captures & one-off probe output ---
|
||||
# Ad-hoc dumps kept while debugging SARS / CIPC screens. Not project source.
|
||||
_probe*.json
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
# --- deps, build output, git history ----------------------------------
|
||||
node_modules
|
||||
.next
|
||||
.next-stale*
|
||||
out
|
||||
dist
|
||||
build
|
||||
|
|
|
|||
|
|
@ -3,11 +3,17 @@
|
|||
// center, secondary status on the right. Sticky on scroll with the
|
||||
// signature hairline border-bottom.
|
||||
//
|
||||
// Mobile (<md): a fixed bottom tab bar with inline-SVG icons (Overview
|
||||
// · Directors · Companies · Onboarding). The second-row horizontal
|
||||
// scroller from earlier turns has been replaced because the tab bar
|
||||
// is the standard mobile-app pattern and reclaims vertical screen
|
||||
// real estate.
|
||||
// Mobile (<md): no tab bar. The header carries a hamburger that opens
|
||||
// a full-screen menu panel listing every section, grouped. A previous
|
||||
// iteration tried a fixed bottom tab bar, but all 15 routes were
|
||||
// crammed into a 390px phone width (≈17px per tab, labels cut to one
|
||||
// letter) — unusable. The full-screen menu keeps every section one
|
||||
// tap away with full-size rows.
|
||||
//
|
||||
// Desktop (md+): primary links inline, everything else behind a
|
||||
// "More" dropdown. The full 15-link row needed 1752px inside a
|
||||
// 1280px container, which pushed the right-hand cluster off-screen
|
||||
// and gave the whole page a horizontal scrollbar.
|
||||
//
|
||||
// Icons use stroke-currentColor at 1.6px per the design system
|
||||
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
|
||||
|
|
@ -18,7 +24,7 @@
|
|||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const LINKS = [
|
||||
|
|
@ -28,6 +34,7 @@ const LINKS = [
|
|||
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
|
||||
{ href: "/cipc", label: "CIPC", icon: IconSearch },
|
||||
{ href: "/cipc-2", label: "CIPC2.0", icon: IconSearch, badge: "test" },
|
||||
{ href: "/cipc-report", label: "CIPC Report", icon: IconCheckSquare },
|
||||
{ href: "/sars-returns", label: "SARS Returns", icon: IconReceipt },
|
||||
{ href: "/sars-registrations", label: "SARS Registrations", icon: IconRegistry },
|
||||
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
|
||||
|
|
@ -38,8 +45,83 @@ const LINKS = [
|
|||
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
|
||||
] as const;
|
||||
|
||||
type LinkItem = (typeof LINKS)[number];
|
||||
|
||||
const LINK_BY_HREF: Record<string, LinkItem> = Object.fromEntries(
|
||||
LINKS.map((l) => [l.href, l]),
|
||||
) as Record<string, LinkItem>;
|
||||
|
||||
/** Sections that stay one click away in the desktop header. */
|
||||
const DESKTOP_PRIMARY = [
|
||||
"/",
|
||||
"/notes",
|
||||
"/companies",
|
||||
"/directors",
|
||||
"/tasks",
|
||||
"/compliance",
|
||||
] as const;
|
||||
|
||||
/** Every section, grouped — the shared source of truth for the mobile
|
||||
* menu panel and the desktop "More" dropdown. */
|
||||
const MENU_GROUPS: { title: string; hrefs: string[] }[] = [
|
||||
{ title: "Main", hrefs: ["/", "/tasks", "/directors", "/companies"] },
|
||||
{
|
||||
title: "CIPC & BizPortal",
|
||||
hrefs: ["/bizportal", "/bizportal-2", "/cipc", "/cipc-2", "/cipc-report"],
|
||||
},
|
||||
{ title: "SARS", hrefs: ["/sars-returns", "/sars-registrations"] },
|
||||
{ title: "Practice", hrefs: ["/notes", "/groups", "/compliance", "/resolutions"] },
|
||||
];
|
||||
|
||||
export default function NavBar() {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Navigating away closes whichever surface was open.
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
setMoreOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// The mobile panel is a modal surface: lock the page behind it.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
// The desktop dropdown closes on an outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMoreOpen(false);
|
||||
};
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onPointer);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onPointer);
|
||||
};
|
||||
}, [moreOpen]);
|
||||
|
||||
const primaryLinks = LINKS.filter((l) =>
|
||||
(DESKTOP_PRIMARY as readonly string[]).includes(l.href),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -47,7 +129,7 @@ export default function NavBar() {
|
|||
data-od-id="navbar"
|
||||
className="sticky top-0 z-30 bg-surface border-b border-border"
|
||||
>
|
||||
<div className="container-x flex items-center justify-between h-20">
|
||||
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
|
||||
<Link
|
||||
href="/"
|
||||
data-od-id="brand-mark"
|
||||
|
|
@ -68,7 +150,7 @@ export default function NavBar() {
|
|||
className="hidden md:flex items-center gap-2"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{LINKS.map((link) => {
|
||||
{primaryLinks.map((link) => {
|
||||
const isActive = isActiveLink(pathname, link.href);
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -83,89 +165,201 @@ export default function NavBar() {
|
|||
}
|
||||
>
|
||||
<span>{link.label}</span>
|
||||
{"badge" in link && link.badge ? (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
||||
{link.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Overflow dropdown — the desktop counterpart of the
|
||||
mobile menu panel. */}
|
||||
<div className="relative" ref={moreRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
aria-expanded={moreOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label="More sections"
|
||||
data-od-id="desktop-more-button"
|
||||
className={
|
||||
"px-4 py-2 rounded-md text-sm font-semibold transition-colors inline-flex items-center gap-1.5 " +
|
||||
(moreOpen || isOverflowActive(pathname)
|
||||
? "bg-surface-warm text-fg"
|
||||
: "text-fg hover:bg-surface-warm")
|
||||
}
|
||||
>
|
||||
More
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={
|
||||
"w-4 h-4 transition-transform " + (moreOpen ? "rotate-180" : "")
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6,9 12,15 18,9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{moreOpen ? (
|
||||
<div
|
||||
data-od-id="desktop-more-panel"
|
||||
className="absolute right-0 top-full mt-2 w-[340px] max-h-[70vh] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised p-2 z-40"
|
||||
>
|
||||
<GroupedList onNavigate={() => setMoreOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Right-side desktop cluster: label + theme toggle. Hidden
|
||||
on mobile — the bottom tab bar carries the brand context. */}
|
||||
on mobile — the menu panel carries both. */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<span className="text-xs text-muted">Registry Browser</span>
|
||||
<span className="hidden lg:inline text-xs text-muted">
|
||||
Registry Browser
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Spacer for mobile so the brand lockup stays left-aligned
|
||||
and the bottom-bar height is the only vertical change. */}
|
||||
<div className="md:hidden" aria-hidden="true" />
|
||||
{/* Mobile: hamburger opens the full-screen menu. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={menuOpen}
|
||||
data-od-id="mobile-menu-button"
|
||||
className="md:hidden inline-flex items-center justify-center w-10 h-10 -mr-2 rounded-md text-fg"
|
||||
>
|
||||
<IconMenu className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile bottom tab bar */}
|
||||
<nav
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{/*
|
||||
Layout notes (phone, full-bleed):
|
||||
- No `container-x` here — the bottom nav should sit edge-to-edge.
|
||||
- Each tab uses `flex-1 min-w-0` so the labels can truncate cleanly
|
||||
instead of pushing siblings or wrapping onto a second line.
|
||||
- `gap-2` between tabs is decorative breathing room; `flex-1` does
|
||||
the real even spacing.
|
||||
- `text-[10px] tracking-tight` keeps 8-tab labels inside ~46px slots
|
||||
on a 390px viewport without truncating every label into dots.
|
||||
*/}
|
||||
<ul className="flex items-stretch justify-between h-16 gap-2 px-2">
|
||||
{LINKS.map((link) => {
|
||||
const isActive = isActiveLink(pathname, link.href);
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<li
|
||||
key={link.href}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={link.label}
|
||||
className={
|
||||
"group flex flex-col items-center justify-center gap-0.5 h-full w-full px-1 no-underline transition-colors " +
|
||||
(isActive
|
||||
? "text-accent"
|
||||
: "text-muted hover:text-fg")
|
||||
}
|
||||
{/* Mobile full-screen menu panel. */}
|
||||
{menuOpen ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Main menu"
|
||||
data-od-id="mobile-menu-panel"
|
||||
className="md:hidden fixed inset-0 z-50 bg-bg overflow-y-auto"
|
||||
>
|
||||
<div className="sticky top-0 bg-surface border-b border-border">
|
||||
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
|
||||
<Link href="/" className="flex items-center gap-2 no-underline">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
"w-5 h-5 shrink-0 transition-transform " +
|
||||
(isActive ? "scale-110" : "scale-100")
|
||||
}
|
||||
active={isActive}
|
||||
/>
|
||||
<span className="text-[10px] font-semibold tracking-tight truncate w-full text-center">
|
||||
{link.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
Y
|
||||
</span>
|
||||
<span className="text-lg font-bold text-fg tracking-tight">
|
||||
Workflow
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-label="Close menu"
|
||||
data-od-id="mobile-menu-close"
|
||||
className="w-10 h-10 -mr-2 inline-flex items-center justify-center rounded-md text-fg"
|
||||
>
|
||||
<IconClose className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GroupedList onNavigate={() => setMenuOpen(false)} />
|
||||
|
||||
<div className="container-x flex items-center justify-between py-6 pb-[calc(env(safe-area-inset-bottom)+24px)]">
|
||||
<span className="text-xs text-muted">Registry Browser</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped list of every section, shared by the mobile menu panel and
|
||||
* the desktop "More" dropdown.
|
||||
*/
|
||||
function GroupedList({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
return (
|
||||
<div className="px-4 pb-2 md:px-0">
|
||||
{MENU_GROUPS.map((group) => (
|
||||
<div key={group.title} className="pt-5">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-meta mb-1 px-1">
|
||||
{group.title}
|
||||
</p>
|
||||
<ul>
|
||||
{group.hrefs.map((href) => {
|
||||
const link = LINK_BY_HREF[href];
|
||||
if (!link) return null;
|
||||
const Icon = link.icon;
|
||||
const active = isActiveLink(pathname, href);
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-3 py-2.5 no-underline rounded-md px-1"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"w-9 h-9 rounded-md inline-flex items-center justify-center shrink-0 " +
|
||||
(active
|
||||
? "bg-accent text-accent-on"
|
||||
: "bg-surface-warm text-muted")
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5" active={active} />
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-[15px] font-semibold " +
|
||||
(active ? "text-accent" : "text-fg")
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</span>
|
||||
{"badge" in link && link.badge ? (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
||||
{link.badge}
|
||||
</span>
|
||||
) : null}
|
||||
<IconChevronRight className="w-4 h-4 ml-auto text-meta shrink-0" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveLink(pathname: string, href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(href + "/");
|
||||
}
|
||||
|
||||
/** True when the current route lives behind the "More" dropdown. */
|
||||
function isOverflowActive(pathname: string): boolean {
|
||||
return LINKS.some(
|
||||
(l) =>
|
||||
!(DESKTOP_PRIMARY as readonly string[]).includes(l.href) &&
|
||||
isActiveLink(pathname, l.href),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
|
||||
|
||||
interface IconProps {
|
||||
|
|
@ -201,7 +395,7 @@ function IconUsers({ className }: IconProps): ReactNode {
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
|
|
@ -221,7 +415,7 @@ function IconBuilding({ className }: IconProps): ReactNode {
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
|
|
@ -407,6 +601,61 @@ function IconNotepad({ className }: IconProps): ReactNode {
|
|||
);
|
||||
}
|
||||
|
||||
/** Hamburger glyph for the mobile menu button. */
|
||||
function IconMenu({ className }: IconProps): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="3.5" y1="7" x2="20.5" y2="7" />
|
||||
<line x1="3.5" y1="12" x2="20.5" y2="12" />
|
||||
<line x1="3.5" y1="17" x2="20.5" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Close glyph for the mobile menu panel. */
|
||||
function IconClose({ className }: IconProps): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="5" x2="19" y2="19" />
|
||||
<line x1="19" y1="5" x2="5" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-chevron used as the list affordance in the grouped lists. */
|
||||
function IconChevronRight({ className }: IconProps): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9.5,5 16.5,12 9.5,19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Theme toggle ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default function RouteChrome({ children }: { children: ReactNode }) {
|
|||
return (
|
||||
<>
|
||||
<NavBar />
|
||||
<main id="main" className="min-h-[60vh] pb-24 md:pb-0">
|
||||
<main id="main" className="min-h-[60vh]">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-surface-warm border-t border-border mt-16">
|
||||
|
|
|
|||
Loading…
Reference in a new issue