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}