Workflow-App/Workflow/components/NavBar.tsx

735 lines
23 KiB
TypeScript
Raw Normal View History

// components/NavBar.tsx
// Top-of-page navigation. Logo lockup on the left, primary nav
// center, secondary status on the right. Sticky on scroll with the
// signature hairline border-bottom.
//
// 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
// mark "Y" pill in the top-left stays on all viewports.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/notes", label: "Notes", icon: IconNotepad },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
{ 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 },
{ href: "/directors", label: "Directors", icon: IconUsers },
{ href: "/companies", label: "Companies", icon: IconBuilding },
{ href: "/groups", label: "Groups", icon: IconTag },
{ href: "/compliance", label: "Compliance", icon: IconCheckSquare },
{ 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 (
<>
<header
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-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
<Link
href="/"
data-od-id="brand-mark"
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"
>
Y
</span>
<span className="text-lg font-bold text-fg tracking-tight">
Workflow
</span>
</Link>
<nav
className="hidden md:flex items-center gap-2"
aria-label="Primary"
>
{primaryLinks.map((link) => {
const isActive = isActiveLink(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
className={
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
(isActive
? "bg-surface-warm text-fg"
: "text-fg hover:bg-surface-warm")
}
>
<span>{link.label}</span>
</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 menu panel carries both. */}
<div className="hidden md:flex items-center gap-3">
<span className="hidden lg:inline text-xs text-muted">
Registry Browser
</span>
<ThemeToggle />
</div>
{/* 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 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"
>
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 {
className?: string;
/** Active state hints — used to swap fill behavior on the home icon
* and to thicken the stroke on outlined icons. Always defaults to
* the brand "muted" rendering since the active state is owned by
* the parent (text color + scale already encodes it). */
active?: boolean;
}
function IconHome({ className, active }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill={active ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M3 11.5L12 4l9 7.5" />
<path d="M5 10.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9.5" />
</svg>
);
}
function IconUsers({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="9" cy="9" r="3.2" />
<path d="M3.5 19c.7-3 3-4.6 5.5-4.6S13.8 16 14.5 19" />
<circle cx="16.5" cy="9" r="2.6" />
<path d="M15.2 14.4c2 0 4 1.4 4.6 4" />
</svg>
);
}
function IconBuilding({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="4" y="3" width="16" height="18" rx="1.5" />
<path d="M8 7h2M8 11h2M8 15h2M14 7h2M14 11h2M14 15h2" />
<path d="M10 21v-3h4v3" />
</svg>
);
}
function IconDocument({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
);
}
function IconClipboard({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="12" y2="17" />
</svg>
);
}
function IconSearch({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
function IconTag({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}
function IconCheckSquare({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
/**
* Tax-receipt glyph for the SARS Returns tab. Deliberately a torn-
* edge receipt (zig-zag bottom) to distinguish it from IconDocument
* (which is a sealed page) and IconClipboard (which has a clip).
*/
function IconReceipt({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M5 3h14v18l-2-1.6L15 21l-2-1.6L11 21l-2-1.6L7 21l-2-1.6V3z" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="9" y1="16" x2="13" y2="16" />
</svg>
);
}
/**
* Registry / ID-badge glyph for the SARS Registrations tab. A card with
* two horizontal ruled lines and a small "stamp" circle in the corner —
* visually distinct from IconReceipt (torn-edge paper), IconNotepad
* (folded top-right page), IconDocument (sealed page), and
* IconClipboard (task list with a clip).
*/
function IconRegistry({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="3.5" y="4.5" width="14" height="13" rx="1.6" />
<line x1="6.5" y1="8.5" x2="14.5" y2="8.5" />
<line x1="6.5" y1="11.5" x2="14.5" y2="11.5" />
<line x1="6.5" y1="14" x2="11.5" y2="14" />
<circle cx="17.5" cy="17" r="3" />
<path d="M16.2 17l1 1 1.6-1.8" />
</svg>
);
}
/**
* Notepad glyph for the Notes tab. Rounded rectangle with a horizontal
* ruled line and a folded top-right corner — distinguishes it from
* IconClipboard (task list with a clip) and IconDocument (sealed page).
*/
function IconNotepad({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8z" />
<polyline points="14,2 14,8 19,8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="14" y2="17" />
</svg>
);
}
/** 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 ────────────────────────────────────────────────── */
/**
* Sun/moon button. Uses next-themes' `resolvedTheme` so system
* preference is honoured. The icon shown represents the mode you'd
* switch TO (moon in light, sun in dark).
*
* `mounted` guard avoids SSR/CSR mismatch — `useTheme` returns
* undefined on the first render. Until mounted, we render a neutral
* placeholder so the button's box doesn't pop in late.
*/
function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => setMounted(true), []);
const isDark = mounted && resolvedTheme === "dark";
const next = isDark ? "light" : "dark";
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={mounted ? `Switch to ${next} mode` : "Toggle theme"}
title={`Switch to ${next} mode`}
data-od-id="theme-toggle"
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-fg hover:bg-surface-warm transition-colors"
>
{mounted ? (
isDark ? (
<IconSun className="w-5 h-5" />
) : (
<IconMoon className="w-5 h-5" />
)
) : (
// Neutral placeholder slot — same dimensions so layout doesn't shift.
<span className="w-5 h-5 block" aria-hidden="true" />
)}
</button>
);
}
function IconSun({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
function IconMoon({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
);
}