按 Supabase 参考修正为横向分类下拉导航 #4
@@ -203,8 +203,11 @@ async function main() {
|
||||
if (route === "/") home = html;
|
||||
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
|
||||
if (route.startsWith("/docs/")) {
|
||||
assert.ok(html.includes('aria-label="文档选择"'), route);
|
||||
assert.ok(html.includes('<optgroup label="语言与运行时">'), route);
|
||||
assert.ok(html.includes('aria-label="文档分类"'), route);
|
||||
assert.ok(html.includes('data-category="语言与运行时"'), route);
|
||||
assert.ok(html.includes('class="category-panel" hidden=""'), route);
|
||||
assert.ok(!html.includes('class="collection-picker"'), route);
|
||||
assert.ok(!html.includes("<select"), route);
|
||||
assert.ok(!html.includes('class="nav-back"'), route);
|
||||
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1319,11 +1319,6 @@ svg { flex-shrink: 0; }
|
||||
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
|
||||
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
|
||||
}
|
||||
.collection-picker { display: flex; flex-wrap: wrap; align-items: center; position: relative; min-width: 180px; max-width: 280px; }
|
||||
.collection-picker select { appearance: none; width: 100%; cursor: pointer; padding: 8px 30px 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font: inherit; font-size: 13px; color: var(--ink); text-overflow: ellipsis; }
|
||||
.picker-category { width: 100%; font-size: 10px; line-height: 1.4; color: var(--muted); margin-bottom: 2px; }
|
||||
.collection-picker > svg { position: absolute; right: 10px; bottom: 12px; pointer-events: none; transform: rotate(90deg); color: var(--muted); }
|
||||
.collection-picker select:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
|
||||
.top-nav { margin-left: 0; }
|
||||
.sidebar { padding: 22px 16px 32px; }
|
||||
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
|
||||
@@ -1347,8 +1342,42 @@ svg { flex-shrink: 0; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.top-nav { overflow: visible; }
|
||||
.collection-picker { width: 100%; max-width: 100%; }
|
||||
.picker-category { display: none; }
|
||||
.collection-picker select { padding-block: 6px; }
|
||||
.mobile-navigation .nav-group { margin-top: 16px; }
|
||||
}
|
||||
|
||||
/* Categories stay visible across the header, each with its own disclosure panel. */
|
||||
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
|
||||
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
|
||||
.category-navigation > ul > li { display: flex; align-items: stretch; }
|
||||
.category-menu { position: relative; }
|
||||
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
|
||||
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
|
||||
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
|
||||
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
|
||||
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
|
||||
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
|
||||
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
|
||||
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
|
||||
.category-panel[hidden] { display: none; }
|
||||
.category-menu:last-child .category-panel { left: auto; right: -16px; }
|
||||
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
|
||||
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
|
||||
.top-nav .category-panel a::after { display: none; }
|
||||
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
|
||||
.category-panel a > svg { margin-top: 2px; color: var(--green); }
|
||||
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
|
||||
@media (min-width: 761px) and (max-width: 1100px) {
|
||||
.site-header { gap: 20px; }
|
||||
.category-navigation > ul { gap: 16px; }
|
||||
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
|
||||
.search-trigger span, .search-trigger kbd { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.top-nav.category-navigation { overflow: visible; }
|
||||
.category-navigation > ul { width: 100%; gap: 18px; }
|
||||
.category-navigation .category-home, .category-trigger { font-size: 12px; }
|
||||
.category-menu { position: static; }
|
||||
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user