Compare commits

..

2 Commits

Author SHA1 Message Date
codex-bot 787491622b Replace collection select with horizontal category dropdown navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Successful in 11m20s
2026-10-28 19:38:07 +08:00
Zayden-Jung e448acb904 Merge pull request '按分类与合集组织文档 URL,收紧导航与阅读排版' (#2) from codex/docs-navigation into main
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m33s
Reviewed-on: #2
2026-10-28 11:16:16 +00:00
4 changed files with 137 additions and 29 deletions
+5 -2
View File
@@ -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>;
}
+2 -19
View File
@@ -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"
+37 -8
View File
@@ -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; }
}