SPB Git

spb/autoscholar

Public
4.7 KB · 141 lines tsx
1"use client";2import Link from "next/link";3import { usePathname } from "next/navigation";4import { type ReactNode, useEffect, useRef, useState } from "react";5import { IconArrow, IconClose, IconMenu } from "./Icons";67/** Client shell for the header: scroll shadow, current-page marking and the accessible mobile drawer. */8export function HeaderChrome({9  links,10  children,11}: {12  links: Array<{ href: string; label: string }>;13  children: ReactNode;14}) {15  const [scrolled, setScrolled] = useState(false);16  const [open, setOpen] = useState(false);17  const pathname = usePathname();18  const panel = useRef<HTMLDivElement>(null);19  const opener = useRef<HTMLButtonElement>(null);2021  useEffect(() => {22    const onScroll = () => setScrolled(window.scrollY > 4);23    onScroll();24    window.addEventListener("scroll", onScroll, { passive: true });25    return () => window.removeEventListener("scroll", onScroll);26  }, []);27  // biome-ignore lint/correctness/useExhaustiveDependencies: close the drawer on navigation28  useEffect(() => {29    setOpen(false);30  }, [pathname]);31  useEffect(() => {32    if (!open) return;33    const prev = document.body.style.overflow;34    document.body.style.overflow = "hidden";35    const first = panel.current?.querySelector<HTMLElement>("a,button");36    first?.focus();37    const onKey = (e: KeyboardEvent) => {38      if (e.key === "Escape") setOpen(false);39      if (e.key === "Tab" && panel.current) {40        const f = [41          ...panel.current.querySelectorAll<HTMLElement>("a,button,input,[tabindex]:not([tabindex='-1'])"),42        ];43        if (f.length === 0) return;44        const a = f[0]!;45        const b = f[f.length - 1]!;46        if (e.shiftKey && document.activeElement === a) {47          e.preventDefault();48          b.focus();49        } else if (!e.shiftKey && document.activeElement === b) {50          e.preventDefault();51          a.focus();52        }53      }54    };55    document.addEventListener("keydown", onKey);56    return () => {57      document.body.style.overflow = prev;58      document.removeEventListener("keydown", onKey);59      opener.current?.focus();60    };61  }, [open]);6263  return (64    <header className="site-header" data-scrolled={scrolled}>65      <div className="relative">66        {children}67        <button68          ref={opener}69          type="button"70          className="btn-icon absolute right-[max(1rem,env(safe-area-inset-right))] top-1/2 -translate-y-1/2 lg:hidden"71          style={{ right: "calc(max(1rem, env(safe-area-inset-right)) + 5.5rem)" }}72          aria-label={open ? "Close menu" : "Open menu"}73          aria-expanded={open}74          aria-controls="mobile-nav"75          onClick={() => setOpen((v) => !v)}76        >77          {open ? <IconClose /> : <IconMenu />}78        </button>79      </div>80      {open && (81        <>82          <button83            type="button"84            className="drawer-backdrop lg:hidden"85            aria-label="Close menu"86            onClick={() => setOpen(false)}87          />88          <div89            ref={panel}90            id="mobile-nav"91            role="dialog"92            aria-modal="true"93            aria-label="Site navigation"94            className="drawer-panel lg:hidden"95          >96            <div className="flex items-center justify-between">97              <span className="eyebrow">Navigate</span>98              <button99                type="button"100                className="btn-icon"101                aria-label="Close menu"102                onClick={() => setOpen(false)}103              >104                <IconClose />105              </button>106            </div>107            <form action="/research" method="get" role="search" className="mt-4">108              <label htmlFor="drawer-q" className="sr-only">109                Search the library110              </label>111              <input112                id="drawer-q"113                name="q"114                type="search"115                placeholder="Search papers, keywords, IDs"116                className="input"117              />118            </form>119            <nav aria-label="Primary mobile" className="mt-4 flex flex-col">120              {links.map((l) => (121                <Link122                  key={l.href}123                  href={l.href}124                  aria-current={pathname === l.href ? "page" : undefined}125                  className="flex items-center justify-between border-b py-3.5 text-base no-underline hairline"126                >127                  <span>{l.label}</span>128                  <IconArrow size={16} className="subtle" />129                </Link>130              ))}131            </nav>132            <p className="subtle mt-6 text-xs leading-relaxed">133              AI-generated working papers; not formally peer reviewed. Research computed on MacLustr.134            </p>135          </div>136        </>137      )}138    </header>139  );140}141
Files · main
Menu

Theme