SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
6.9 KB · 149 lines tsx
Raw Blame History
1'use client';2import { ChevronDown, Search } from 'lucide-react';3import Link from 'next/link';4import { usePathname } from 'next/navigation';5import { useEffect, useRef, useState } from 'react';6import { Wordmark } from '@/components/brand/logo';7import { cn } from '@/lib/cn';8import { moreGroups, primaryNav, routes } from '@/lib/site';9import { DensityToggle } from './density';10import { useOpenSearch } from './search-context';11import { ThemeToggle } from './theme';1213const MORE_HREFS = moreGroups.flatMap((g) => g.items.map((i) => i.href));1415/**16 * Header: `AI Atlas | Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾ | Search ⌘K | theme | density`.17 * "More" is a keyboard-accessible menu panel (Enter/Space/↓ opens, ↑↓ move, Home/End, Esc closes, click outside closes).18 * Below lg the primary nav lives in the bottom tab bar (`MobileTabBar`); the header keeps search, theme and density.19 */20export function SiteHeader() {21  const pathname = usePathname();22  const openSearch = useOpenSearch();23  const [more, setMore] = useState(false);24  const moreRef = useRef<HTMLDivElement>(null);25  const moreBtn = useRef<HTMLButtonElement>(null);2627  useEffect(() => setMore(false), [pathname]);28  useEffect(() => {29    if (!more) return;30    const onDoc = (e: MouseEvent) => {31      if (!moreRef.current?.contains(e.target as Node)) setMore(false);32    };33    const onKey = (e: KeyboardEvent) => {34      if (e.key === 'Escape') {35        setMore(false);36        moreBtn.current?.focus();37      }38    };39    document.addEventListener('mousedown', onDoc);40    document.addEventListener('keydown', onKey);41    const first = moreRef.current?.querySelector<HTMLElement>('[role="menuitem"]');42    first?.focus();43    return () => {44      document.removeEventListener('mousedown', onDoc);45      document.removeEventListener('keydown', onKey);46    };47  }, [more]);4849  const onMenuKey = (e: React.KeyboardEvent) => {50    const items = [...(moreRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];51    const i = items.indexOf(document.activeElement as HTMLElement);52    if (e.key === 'ArrowDown') {53      e.preventDefault();54      items[(i + 1) % items.length]?.focus();55    } else if (e.key === 'ArrowUp') {56      e.preventDefault();57      items[(i - 1 + items.length) % items.length]?.focus();58    } else if (e.key === 'Home') {59      e.preventDefault();60      items[0]?.focus();61    } else if (e.key === 'End') {62      e.preventDefault();63      items[items.length - 1]?.focus();64    } else if (e.key === 'Tab') setMore(false);65  };6667  const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');68  const moreActive = MORE_HREFS.some(isActive) && !primaryNav.some((n) => isActive(n.href));69  const navCls = (active: boolean) => cn('flex h-9 items-center gap-1 rounded-sm px-2.5 text-[13.5px] transition-colors', active ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink');7071  return (72    <header className="sticky top-0 z-40 border-b border-rule bg-canvas/85 backdrop-blur-md">73      <div className="container-x mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-3">74        <Link href={routes.home()} className="flex h-11 items-center" aria-label="AI Atlas home">75          <Wordmark />76        </Link>77        <nav aria-label="Primary" className="relative ml-3 hidden items-center gap-0.5 lg:flex">78          {primaryNav.map((n) => (79            <Link key={n.href} href={n.href} aria-current={isActive(n.href) ? 'page' : undefined} className={navCls(isActive(n.href))}>80              {n.label}81            </Link>82          ))}83          <div ref={moreRef} onKeyDown={onMenuKey}>84            <button85              ref={moreBtn}86              type="button"87              onClick={() => setMore((v) => !v)}88              onKeyDown={(e) => {89                if (e.key === 'ArrowDown' && !more) {90                  e.preventDefault();91                  setMore(true);92                }93              }}94              aria-expanded={more}95              aria-haspopup="menu"96              aria-controls="more-menu"97              className={navCls(moreActive)}98              data-more-button99            >100              More <ChevronDown className={cn('size-3.5 transition-transform', more && 'rotate-180')} aria-hidden />101            </button>102            {more && (103              <div id="more-menu" role="menu" aria-label="More" className="panel absolute left-0 top-full mt-1 w-[min(880px,calc(100vw-2rem))] p-3 shadow-lg" data-more-menu>104                <div className="grid grid-cols-4 gap-x-6">105                  {moreGroups.map((g) => (106                    <div key={g.label}>107                      <p className="eyebrow px-2 pb-1">{g.label}</p>108                      <ul>109                        {g.items.map((n) => (110                          <li key={n.href + n.label}>111                            <Link role="menuitem" href={n.href} tabIndex={-1} className={cn('block rounded-sm px-2 py-1.5 text-sm leading-tight hover:bg-surface-2 focus:bg-surface-2 focus:outline-none', isActive(n.href) ? 'text-ink font-medium' : 'text-ink-2 hover:text-ink')}>112                              {n.label}113                              {n.hint && <span className="mt-0.5 block text-[11px] text-ink-3">{n.hint}</span>}114                            </Link>115                          </li>116                        ))}117                      </ul>118                    </div>119                  ))}120                </div>121                <div className="mt-2 flex items-center justify-between border-t border-rule px-2 pt-2 text-xs text-ink-3">122                  <span>123                    <kbd className="mono border border-rule px-1">⌘K</kbd> search anything · <kbd className="mono border border-rule px-1">&gt;</kbd> commands124                  </span>125                  <Link role="menuitem" href={routes.explore()} tabIndex={-1} className="link">126                    All entity types with live counts →127                  </Link>128                </div>129              </div>130            )}131          </div>132        </nav>133        <div className="ml-auto flex items-center gap-1">134          <button type="button" onClick={openSearch} className="hidden h-9 min-w-[260px] items-center gap-2 rounded-sm border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 md:flex" aria-label="Open search and commands" data-open-palette>135            <Search className="size-4" aria-hidden />136            <span className="flex-1 text-left">Search or run a command…</span>137            <kbd className="mono rounded-[3px] border border-rule px-1.5 py-0.5 text-[10px]">⌘K</kbd>138          </button>139          <button type="button" onClick={openSearch} className="flex size-11 items-center justify-center rounded-sm text-ink-2 hover:bg-surface-2 hover:text-ink md:hidden" aria-label="Open search">140            <Search className="size-5" aria-hidden />141          </button>142          <ThemeToggle />143          <DensityToggle />144        </div>145      </div>146    </header>147  );148}149