SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
8.7 KB · 183 lines tsx
Raw Blame History
1"use client";23import { ChevronDown, Menu, Search, X } from "lucide-react";4import Link from "next/link";5import { usePathname } from "next/navigation";6import { useEffect, useRef, useState } from "react";7import { Logo } from "@/components/brand/logo";8import { openPalette } from "@/components/command-palette";9import { ThemeToggle } from "@/components/theme";10import { IconButton } from "@/components/ui/button";11import { cn } from "@/components/ui/cn";12import { Kbd } from "@/components/ui/kbd";13import { NAV_MORE, NAV_PRIMARY, routes } from "@/lib/routes";1415/** "More" menu groups (§102) built from NAV_MORE so new routes only need lib/routes.ts. */16const MORE_GROUPS: Array<{ title: string; hrefs: string[] }> = [17  { title: "Index", hrefs: [routes.countries(), routes.cloudRegions(), routes.ixps(), routes.power(), routes.connectivity(), routes.rankings()] },18  { title: "Intelligence", hrefs: [routes.pulse(), routes.explore(), routes.compareOperators(), routes.coverage(), routes.watchlist()] },19  { title: "Reference", hrefs: [routes.sources(), routes.methodology(), routes.download(), routes.apiDocsPage(), routes.about()] },20];2122function isActive(path: string, href: string): boolean {23  if (href === "/") return path === "/";24  if (href === "/datacenters") return path.startsWith("/datacenters") && !path.startsWith("/datacenters/markets");25  return path === href || path.startsWith(`${href}/`) || path.startsWith(`${href}?`);26}2728export function Header() {29  const path = usePathname();30  const [menu, setMenu] = useState(false);31  const [more, setMore] = useState(false);32  const [menuPath, setMenuPath] = useState(path);33  const moreRef = useRef<HTMLDivElement>(null);34  if (menuPath !== path) {35    setMenuPath(path);36    setMenu(false);37    setMore(false);38  }39  useEffect(() => {40    if (!menu) return;41    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenu(false);42    document.addEventListener("keydown", onKey);43    document.body.style.overflow = "hidden";44    return () => {45      document.removeEventListener("keydown", onKey);46      document.body.style.overflow = "";47    };48  }, [menu]);49  useEffect(() => {50    if (!more) return;51    const onDoc = (e: MouseEvent) => {52      if (!moreRef.current?.contains(e.target as Node)) setMore(false);53    };54    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMore(false);55    document.addEventListener("mousedown", onDoc);56    document.addEventListener("keydown", onKey);57    return () => {58      document.removeEventListener("mousedown", onDoc);59      document.removeEventListener("keydown", onKey);60    };61  }, [more]);62  const isMap = path === "/map";63  const moreActive = NAV_MORE.some((n) => isActive(path, n.href));64  const byHref = new Map(NAV_MORE.map((n) => [n.href, n] as const));6566  return (67    <header className={cn("sticky top-0 z-[60] border-b border-hair bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/80", isMap && "relative")}>68      <div className="mx-auto flex h-12 w-full max-w-[1600px] items-center gap-2 px-3 sm:h-14 sm:px-5 lg:px-8">69        <Logo size={22} />70        <nav aria-label="Primary" className="ml-3 hidden items-center gap-0 lg:flex xl:ml-4 xl:gap-0.5">71          {NAV_PRIMARY.map((n) => {72            const active = isActive(path, n.href);73            return (74              <Link75                key={n.href}76                href={n.href}77                aria-current={active ? "page" : undefined}78                className={cn("relative inline-flex h-9 items-center whitespace-nowrap rounded-sm px-2 text-[13px] font-medium text-ink-2 transition-colors hover:text-ink xl:px-3", active && "text-ink after:absolute after:inset-x-2 after:-bottom-[9px] after:h-[2px] after:bg-ink sm:after:-bottom-[13px]")}79              >80                {n.label}81                {n.label === "Live" && <span aria-hidden className="ml-1.5 inline-block size-1.5 rounded-full bg-accent animate-pulse-soft" />}82              </Link>83            );84          })}85          <div ref={moreRef} className="relative">86            <button87              type="button"88              onClick={() => setMore((m) => !m)}89              aria-expanded={more}90              aria-haspopup="menu"91              aria-controls="more-menu"92              className={cn("relative inline-flex h-9 items-center gap-1 whitespace-nowrap rounded-sm px-2 text-[13px] font-medium text-ink-2 transition-colors hover:text-ink xl:px-3", (more || moreActive) && "text-ink", moreActive && "after:absolute after:inset-x-2 after:-bottom-[9px] after:h-[2px] after:bg-ink sm:after:-bottom-[13px]")}93            >94              More <ChevronDown className={cn("size-3.5 transition-transform", more && "rotate-180")} strokeWidth={1.75} aria-hidden />95            </button>96            {more && (97              <div id="more-menu" role="menu" className="absolute left-0 top-full z-[70] mt-2 grid w-[560px] grid-cols-3 gap-4 rounded-sm border border-hair-2 bg-surface p-4 animate-fade-in">98                {MORE_GROUPS.map((g) => (99                  <div key={g.title}>100                    <div className="label-caps mb-1.5">{g.title}</div>101                    <ul className="flex flex-col">102                      {g.hrefs.map((h) => {103                        const n = byHref.get(h);104                        if (!n) return null;105                        return (106                          <li key={h}>107                            <Link href={h} role="menuitem" aria-current={isActive(path, h) ? "page" : undefined} className={cn("flex h-8 items-center rounded-xs px-1.5 text-[13px] text-ink-2 hover:bg-surface-2 hover:text-ink", isActive(path, h) && "font-medium text-ink")}>108                              {n.label}109                            </Link>110                          </li>111                        );112                      })}113                    </ul>114                  </div>115                ))}116              </div>117            )}118          </div>119        </nav>120        <div className="ml-auto flex items-center gap-1">121          <button122            type="button"123            onClick={() => openPalette()}124            className="hidden h-9 items-center gap-2 rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink-3 hover:border-ink-4 hover:text-ink md:inline-flex md:w-[240px] lg:w-[190px] xl:w-[240px]"125            aria-label="Search (Command K)"126          >127            <Search className="size-4" strokeWidth={1.75} />128            <span className="flex-1 text-left">Search the index…</span>129            <span className="inline-flex gap-0.5">130              <Kbd>⌘</Kbd>131              <Kbd>K</Kbd>132            </span>133          </button>134          <IconButton label="Search" className="md:hidden" onClick={() => openPalette()}>135            <Search className="size-[18px]" strokeWidth={1.75} />136          </IconButton>137          <ThemeToggle />138          <IconButton label={menu ? "Close menu" : "Menu"} onClick={() => setMenu((m) => !m)} aria-expanded={menu} aria-controls="site-menu" className="lg:hidden">139            {menu ? <X className="size-[18px]" strokeWidth={1.75} /> : <Menu className="size-[18px]" strokeWidth={1.75} />}140          </IconButton>141        </div>142      </div>143144      {menu && (145        <div id="site-menu" className="absolute inset-x-0 top-full z-[60] max-h-[calc(100dvh-3rem)] overflow-y-auto border-b border-hair bg-paper animate-fade-in lg:hidden">146          <div className="mx-auto grid max-w-[1600px] gap-6 px-3 py-4 sm:grid-cols-2 sm:px-5">147            <div>148              <div className="label-caps mb-2">Explore</div>149              <ul className="grid grid-cols-2 gap-x-3">150                {NAV_PRIMARY.map((n) => (151                  <li key={n.href}>152                    <Link href={n.href} aria-current={isActive(path, n.href) ? "page" : undefined} className="flex h-11 items-center border-b border-hair text-[14px] font-medium text-ink">153                      {n.label}154                    </Link>155                  </li>156                ))}157              </ul>158            </div>159            {MORE_GROUPS.map((g) => (160              <div key={g.title}>161                <div className="label-caps mb-2">{g.title}</div>162                <ul className="grid grid-cols-2 gap-x-3">163                  {g.hrefs.map((h) => {164                    const n = byHref.get(h);165                    if (!n) return null;166                    return (167                      <li key={h}>168                        <Link href={h} aria-current={isActive(path, h) ? "page" : undefined} className="flex h-11 items-center border-b border-hair text-[14px] text-ink-2">169                          {n.label}170                        </Link>171                      </li>172                    );173                  })}174                </ul>175              </div>176            ))}177          </div>178        </div>179      )}180    </header>181  );182}183