TheBird

Header.jsx

10.2 kB · jsx · 291 lines

1import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";2import { CATEGORIES, PAGES, SHOP } from "../config/shop.ts";3import { checkoutUrl, count, load, server, subscribe, total } from "../lib/cart.ts";4import { useMediaQuery } from "../lib/hooks.ts";5import { collectionUrl, lineUrl, money } from "../lib/shop.ts";6import { get as getMode, server as modeServer, subscribe as onMode, toggle } from "../client/mode.ts";7import { Icon } from "./Icon.jsx";89const CART = "/cart/";1011const HOVER = 180;1213const LEAVE = 260;1415const LIMIT = 12;1617/* SEARCH */1819let index = null;2021async function hits() {22  if (index) return index;23  try {24    index = await (await fetch("/search.json")).json();25  } catch {26    index = [];27  }28  return index;29}3031function Finder({ links, on }) {32  const [text, setText] = useState("");33  const [found, setFound] = useState([]);34  const box = useRef(null);3536  useEffect(() => {37    if (on) box.current?.focus();38  }, [on]);3940  useEffect(() => {41    const want = text.trim().toLowerCase();42    if (!want) return;43    let live = true;44    const words = want.split(/\s+/);45    void hits().then((all) => {46      if (!live) return;47      setFound(all.filter((hit) => words.every((word) => `${hit.name} ${hit.kind} ${hit.tags ?? ""}`.toLowerCase().includes(word))).slice(0, LIMIT));48    });49    return () => {50      live = false;51    };52  }, [text]);5354  const empty = !text.trim();55  return (56    <div className="pane finder" hidden={!on}>57      <form className="field" role="search" onSubmit={(event) => event.preventDefault()}>58        <Icon name="search" />59        <input ref={box} type="search" name="q" placeholder="Search" autoComplete="off" aria-label="Search" value={text} onChange={(event) => setText(event.target.value)} onFocus={() => void hits()} />60      </form>61      <p className="fine" hidden={!empty}>62        Quick Links63      </p>64      <ul className="quick" hidden={!empty}>65        {links.map((one, i) => (66          <li key={one.href} style={{ "--i": i }}>67            <a href={one.href}>68              <Icon name="arrow_forward" extra="small" />69              {one.name}70            </a>71          </li>72        ))}73      </ul>74      <ul className="quick results" aria-live="polite" hidden={empty}>75        {found.map((hit, i) => (76          <li key={hit.href} style={{ "--i": i }}>77            <a href={hit.href}>78              <Icon name="arrow_forward" extra="small" />79              {hit.name}80              <small>{hit.kind}</small>81            </a>82          </li>83        ))}84        {!found.length && !empty ? <li className="none">No results.</li> : null}85      </ul>86    </div>87  );88}8990/* BAG */9192function Purse({ items, on }) {93  const n = count(items);94  return (95    <div className="pane purse" hidden={!on}>96      <p className="fine">{n ? `Your Bag · ${n} item${n === 1 ? "" : "s"} · ${money(total(items))}` : "Your Bag"}</p>97      <ul className="lines">98        {items.map((item) => (99          <li key={item.id}>100            <a href={lineUrl(item.key)}>101              <img src={item.image} alt={item.key} width="48" height="48" />102              <span>{item.title}</span>103              <small>{`${item.size} · ${money(item.price)}${item.qty > 1 ? ` × ${item.qty}` : ""}`}</small>104            </a>105          </li>106        ))}107      </ul>108      <p className="lead" hidden={items.length > 0}>109        Your bag is empty.110      </p>111      <p className="get">112        <a className="pill go" href={CART}>113          Review Bag114        </a>115        {items.length ? (116          <a className="pill" href={checkoutUrl(items) || CART}>117            Checkout118          </a>119        ) : null}120      </p>121    </div>122  );123}124125/* HEADER */126127export function Header({ route, catalog = [], fly = false }) {128  const links = [{ slug: "all", name: "All", href: SHOP }, ...CATEGORIES.map(([slug, name]) => ({ slug, name, href: `${SHOP}${slug}/` })), { slug: "pages", name: "Pages", href: "/pages/" }];129  const items = useSyncExternalStore(subscribe, load, server);130  const mode = useSyncExternalStore(onMode, getMode, modeServer);131  const wide = useMediaQuery("(min-width: 768px)");132  const [open, setOpen] = useState("");133  const [level, setLevel] = useState(1);134  const timer = useRef(0);135  const n = count(items);136137  const show = useCallback((name, deep = 1) => {138    clearTimeout(timer.current);139    setOpen(name);140    setLevel(deep);141  }, []);142143  const hide = useCallback(() => {144    clearTimeout(timer.current);145    setOpen("");146    setLevel(1);147  }, []);148149  const later = useCallback(150    (name, wait) => {151      clearTimeout(timer.current);152      timer.current = setTimeout(() => (name ? show(name) : hide()), wait);153    },154    [hide, show],155  );156157  useEffect(() => () => clearTimeout(timer.current), []);158159  useEffect(() => {160    document.documentElement.classList.toggle("held", Boolean(open) && !wide);161  }, [open, wide]);162163  useEffect(() => hide(), [hide, route, wide]);164165  useEffect(() => {166    if (!open) return;167    const key = (event) => {168      if (event.key === "Escape") hide();169    };170    const away = (event) => {171      if (event.target instanceof Element && !event.target.closest(".top")) hide();172    };173    document.addEventListener("keydown", key);174    document.addEventListener("click", away);175    return () => {176      document.removeEventListener("keydown", key);177      document.removeEventListener("click", away);178    };179  }, [hide, open]);180181  const sticky = open === "search" || open === "bag" || (open && !wide);182  const current = (href) => (route === href || (href !== SHOP && route.startsWith(href)) ? "page" : undefined);183  const rows = (slug) =>184    slug === "all"185      ? CATEGORIES.map(([one, name]) => ({ title: name, href: `${SHOP}${one}/` }))186      : slug === "pages"187        ? PAGES.filter((one) => one.href !== CART).map((one) => ({ title: one.name, href: one.href }))188        : catalog.filter((row) => row.category === slug).map((row) => ({ title: row.title, href: collectionUrl(row.handle) }));189190  return (191    <header className="top" data-open={open || undefined} data-level={open ? String(level) : undefined} onMouseLeave={() => !sticky && later("", LEAVE)}>192      <div className="bar">193        <div className="home" onMouseEnter={() => !sticky && later("", LEAVE)}>194          <a className="mark" href="/" aria-label={fly ? "TheBird" : "Home"} data-fly={fly ? "" : undefined}>195            <img className="light" src="/bird/mark-light-128.png" srcSet="/bird/mark-light-256.png 2x" alt="TheBird" width="28" height="28" />196            <img className="dark" src="/bird/mark-dark-128.png" srcSet="/bird/mark-dark-256.png 2x" alt="" width="28" height="28" />197          </a>198          <button className="tool back" type="button" aria-label="Back" onClick={() => show("menu")}>199            <Icon name="chevron_left" />200          </button>201        </div>202        <nav className="links" aria-label="Shop">203          <ul>204            {links.map((one) => (205              <li key={one.href}>206                <a207                  href={one.href}208                  className={open === one.slug ? "lit" : undefined}209                  aria-current={current(one.href)}210                  onMouseEnter={() => wide && later(one.slug, open ? 0 : HOVER)}211                  onFocus={() => wide && show(one.slug)}212                >213                  {one.name}214                </a>215              </li>216            ))}217          </ul>218        </nav>219        <div className="tools">220          <button className="tool" type="button" aria-label="Search" onMouseEnter={() => {221              void hits();222              if (!sticky) later("", LEAVE);223            }} onClick={() => (open === "search" ? hide() : show("search"))}>224            <Icon name="search" />225          </button>226          <button className="tool mode" type="button" aria-label={mode === "dark" ? "Switch to light mode" : "Switch to dark mode"} onClick={toggle}>227            <Icon name="dark_mode" extra="moon" />228            <Icon name="light_mode" extra="sun" />229          </button>230          <a231            className="tool bag"232            href={CART}233            aria-label="Bag"234            onClick={(event) => {235              if (!wide) return;236              event.preventDefault();237              if (open === "bag") hide();238              else show("bag");239            }}240          >241            <Icon name="shopping_bag" />242            <span className="count" hidden={!n}>243              {n ? String(n) : ""}244            </span>245          </a>246          <button className="tool burger" type="button" aria-label="Menu" aria-expanded={String(Boolean(open) && !wide)} onClick={() => (open ? hide() : show("menu"))}>247            <Icon name="menu" extra="open" />248            <Icon name="close" extra="shut" />249          </button>250        </div>251      </div>252      <div className="flyout" onMouseEnter={() => clearTimeout(timer.current)}>253        <div className="inside">254          <div className="wrap">255            {links.map((one) => (256              <div key={one.slug} className="pane explore" hidden={open !== one.slug}>257                <div className="list">258                  <p className="fine">{one.name}</p>259                  <a className="lead" href={one.href}>260                    {one.slug === "all" ? "Explore All Variations" : one.slug === "pages" ? "Explore All Pages" : `Explore All ${one.name}`}261                  </a>262                  <ul className="cols">263                    {rows(one.slug).map((row, i) => (264                      <li key={row.href} style={{ "--i": i }}>265                        <a href={row.href}>{row.title}</a>266                      </li>267                    ))}268                  </ul>269                </div>270              </div>271            ))}272            <Finder links={links} on={open === "search"} />273            <div className="pane sheet" hidden={open !== "menu"}>274              <ul className="big">275                {links.map((one, i) => (276                  <li key={one.href} style={{ "--i": i }}>277                    <button type="button" aria-current={current(one.href)} onClick={() => show(one.slug, 2)}>278                      <span>{one.name}</span>279                      <Icon name="chevron_right" extra="small" />280                    </button>281                  </li>282                ))}283              </ul>284            </div>285            <Purse items={items} on={open === "bag"} />286          </div>287        </div>288      </div>289    </header>290  );291}