94 lines
4.4 KiB
TypeScript
94 lines
4.4 KiB
TypeScript
import Link from "next/link";
|
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
|
import type { DocLink } from "@/lib/docs";
|
|
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
|
|
import { Icon } from "./Icon";
|
|
|
|
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
|
|
const navigation = useRef<HTMLElement>(null);
|
|
const triggers = useRef(new Map<string, HTMLButtonElement>());
|
|
const [open, setOpen] = useState<string | null>(null);
|
|
const id = useId();
|
|
const categories = [...new Set(docs.map((doc) => doc.category))];
|
|
const collections = collectionEntries(docs);
|
|
const selected = docs.find((doc) => doc.slug === current);
|
|
|
|
useEffect(() => {
|
|
function outside(event: PointerEvent) {
|
|
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
|
|
}
|
|
function escape(event: globalThis.KeyboardEvent) {
|
|
if (event.key !== "Escape" || !open) return;
|
|
if (navigation.current?.contains(document.activeElement)) {
|
|
event.preventDefault();
|
|
triggers.current.get(open)?.focus();
|
|
}
|
|
setOpen(null);
|
|
}
|
|
document.addEventListener("pointerdown", outside);
|
|
document.addEventListener("keydown", escape);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", outside);
|
|
document.removeEventListener("keydown", escape);
|
|
};
|
|
}, [open]);
|
|
|
|
function focusItem(panel: string, last = false) {
|
|
requestAnimationFrame(() => {
|
|
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
|
|
links?.[last ? links.length - 1 : 0]?.focus();
|
|
});
|
|
}
|
|
function move(event: KeyboardEvent<HTMLDivElement>) {
|
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
|
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
|
|
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
|
|
if (index < 0) return;
|
|
event.preventDefault();
|
|
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
|
|
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
|
|
links[next]?.focus();
|
|
}
|
|
|
|
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
|
|
}}>
|
|
<ul>
|
|
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
|
|
{categories.map((category, index) => {
|
|
const panel = `${id}-category-${index}`;
|
|
const expanded = open === category;
|
|
return <li className="category-menu" key={category}
|
|
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
|
|
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
|
|
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
|
|
type="button" className="category-trigger" data-category={category}
|
|
data-active={selected?.category === category || undefined}
|
|
aria-expanded={expanded} aria-controls={panel}
|
|
onClick={() => setOpen(expanded ? null : category)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setOpen(category);
|
|
focusItem(panel, event.key === "ArrowUp");
|
|
}
|
|
}}>
|
|
{category}<Icon name="chevron" size={11} />
|
|
</button>
|
|
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
|
|
<p className="category-panel-title">{category}</p>
|
|
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
|
|
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
|
|
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
|
|
onClick={() => setOpen(null)}>
|
|
<Icon name={collectionIcon(name)} size={19} />
|
|
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
|
|
</Link>)}
|
|
</div>
|
|
</li>;
|
|
})}
|
|
</ul>
|
|
</nav>;
|
|
}
|