Replace collection select with horizontal category dropdown navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { CategoryNavigation } from "./CategoryNavigation";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import type { DocLink } from "@/lib/docs";
|
||||
@@ -69,7 +69,6 @@ export function Shell({
|
||||
current?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
@@ -80,7 +79,6 @@ export function Shell({
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const collection = docs.find((d) => d.slug === current)?.collection;
|
||||
const collections = collectionEntries(docs);
|
||||
const results = searchDocs.filter((d) =>
|
||||
(!collection || d.collection === collection) &&
|
||||
`${d.title} ${d.description} ${d.searchText ?? ""}`
|
||||
@@ -125,22 +123,7 @@ export function Shell({
|
||||
</a>
|
||||
<header className="site-header">
|
||||
<Brand />
|
||||
<nav className="top-nav" aria-label="文档选择">
|
||||
<label className="collection-picker">
|
||||
<span className="picker-category">{docs.find((d) => d.slug === current)?.category ?? "全部分类"}</span>
|
||||
<span className="sr-only">选择分类与文档合集</span>
|
||||
<select value={collection ?? ""} onChange={(event) => {
|
||||
const entry = collections.find((c) => c.name === event.target.value)?.entry;
|
||||
void router.push(entry ? `/docs/${entry.slug}` : "/");
|
||||
}}>
|
||||
<option value="">文档中心</option>
|
||||
{[...new Set(docs.map((d) => d.category))].map((category) => <optgroup label={category} key={category}>
|
||||
{collections.filter((c) => c.entry.category === category).map(({name}) => <option value={name} key={name}>{collectionLabel(name)}</option>)}
|
||||
</optgroup>)}
|
||||
</select>
|
||||
<Icon name="chevron" size={13} />
|
||||
</label>
|
||||
</nav>
|
||||
<CategoryNavigation docs={docs} current={current} />
|
||||
<button
|
||||
ref={trigger}
|
||||
className="search-trigger"
|
||||
|
||||
Reference in New Issue
Block a user