Files
simple-react-app-kit/docs/src/pages/article.tsx
T

720 lines
22 KiB
TypeScript

import * as React from "react"
import { createPortal } from "react-dom"
import { Icon } from "@icones/react"
import {
getDocHref,
getDocPackages,
getDocSectionLabel,
getDocsHomeHref,
resolveDocLocation,
} from "../content/registry"
import type {
DocLocation,
DocPage,
DocTocItem,
} from "../content/types"
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
import { mdxComponents } from "../components/mdx"
import { useLocale, useTranslate } from "../lib/locale"
const noTocItems: readonly DocTocItem[] = []
export default function ArticlePage() {
const t = useTranslate()
const docLocation = resolveDocLocation(window.location.pathname)
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
const [mobilePanel, setMobilePanel] = React.useState<
"navigation" | "toc" | null
>(null)
const mobilePanelRef = React.useRef<HTMLDivElement>(null)
const navigationTriggerRef = React.useRef<HTMLButtonElement>(null)
const tocTriggerRef = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (!mobilePanel) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const unavailableAt = window.matchMedia(
mobilePanel === "navigation" ? "(min-width: 48rem)" : "(min-width: 64rem)"
)
const animationFrame = window.requestAnimationFrame(() => {
mobilePanelRef.current
?.querySelector<HTMLElement>(
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
)
?.focus()
})
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault()
setMobilePanel(null)
const trigger =
mobilePanel === "navigation"
? navigationTriggerRef.current
: tocTriggerRef.current
trigger?.focus()
return
}
if (event.key !== "Tab") return
const focusableElements = Array.from(
mobilePanelRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
) ?? []
)
const firstElement = focusableElements[0]
const lastElement = focusableElements.at(-1)
if (!firstElement || !lastElement) return
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
function handleBreakpointChange(event: MediaQueryListEvent) {
if (event.matches) setMobilePanel(null)
}
document.addEventListener("keydown", handleKeyDown)
unavailableAt.addEventListener("change", handleBreakpointChange)
return () => {
window.cancelAnimationFrame(animationFrame)
document.removeEventListener("keydown", handleKeyDown)
unavailableAt.removeEventListener("change", handleBreakpointChange)
document.body.style.overflow = previousOverflow
}
}, [mobilePanel])
if (!docLocation) return <ArticleNotFound />
const currentIndex = docLocation.package.pages.indexOf(docLocation.page)
const previousPage = docLocation.package.pages[currentIndex - 1]
const nextPage = docLocation.package.pages[currentIndex + 1]
const previous = previousPage
? {
locale: docLocation.locale,
package: docLocation.package,
page: previousPage,
}
: undefined
const next = nextPage
? {
locale: docLocation.locale,
package: docLocation.package,
page: nextPage,
}
: undefined
return (
<>
<header
data-article-sticky-header
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
>
<Container className="flex size-full items-center gap-4">
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
<SlashLine />
<h1 className="truncate">{docLocation.package.label}</h1>
<Actions className="ml-auto" />
</Container>
</header>
<section
data-article-sticky-header
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 backdrop-blur-sm md:hidden dark:border-slate-800 dark:bg-slate-950/80"
>
<button
ref={navigationTriggerRef}
type="button"
className="inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
aria-controls="article-mobile-navigation"
aria-expanded={mobilePanel === "navigation"}
onClick={() =>
setMobilePanel((current) =>
current === "navigation" ? null : "navigation"
)
}
>
<Icon name="tabler:menu" className="size-5" />
<span>{t({ zh: "文档菜单", us: "Docs Menu" })}</span>
</button>
<button
ref={tocTriggerRef}
type="button"
className="ml-auto inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
aria-controls="article-mobile-toc"
aria-expanded={mobilePanel === "toc"}
onClick={() =>
setMobilePanel((current) => (current === "toc" ? null : "toc"))
}
>
<span>{t({ zh: "本页内容", us: "On this page" })}</span>
<Icon name="tabler:list" className="size-5 rotate-180" />
</button>
</section>
{mobilePanel &&
typeof document !== "undefined" &&
createPortal(
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] dark:bg-black/70"
aria-hidden="true"
onClick={() => {
const trigger =
mobilePanel === "navigation"
? navigationTriggerRef.current
: tocTriggerRef.current
setMobilePanel(null)
window.requestAnimationFrame(() => trigger?.focus())
}}
/>
<div
ref={mobilePanelRef}
id={
mobilePanel === "navigation"
? "article-mobile-navigation"
: "article-mobile-toc"
}
role="dialog"
aria-modal="true"
aria-label={
mobilePanel === "navigation"
? t({ zh: "文档导航", us: "Documentation navigation" })
: t({ zh: "本页内容", us: "On this page" })
}
className={[
"absolute inset-y-0 w-[min(88vw,35rem)] overflow-y-auto overscroll-contain bg-white p-7 shadow-2xl dark:bg-slate-950 dark:shadow-black/60",
mobilePanel === "navigation" ? "left-0" : "right-0",
].join(" ")}
onClick={(event) => {
if (
event.target instanceof Element &&
event.target.closest("a[href]")
) {
setMobilePanel(null)
}
}}
>
{mobilePanel === "navigation" ? (
<nav aria-label="Documentation navigation">
<Sidebar current={docLocation} />
</nav>
) : (
<TableOfContents
activeIds={visibleTocIds}
items={docLocation.page.toc}
/>
)}
</div>
</div>,
document.body
)}
<div className="flex w-full">
<div
data-slot="left"
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
>
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
<Sidebar current={docLocation} />
</aside>
</div>
<div
data-slot="center"
className="min-w-0 flex-1 sm:flex-[0_1_var(--cw)]"
>
<main className="w-full max-w-(--cw) px-4 pt-8 pb-16 md:px-22">
<Header docLocation={docLocation} />
<ArticleContent page={docLocation.page} />
<Footer next={next} previous={previous} />
</main>
</div>
<div
data-slot="right"
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-rw) flex-[5_1_var(--min-rw)] lg:block"
>
<div className="h-full w-full max-w-(--max-rw)">
<TableOfContents
activeIds={visibleTocIds}
items={docLocation.page.toc}
/>
</div>
</div>
</div>
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
<Container className="flex h-full w-full items-center">
Copyright © 2026
</Container>
</footer>
</>
)
}
function Sidebar({ current }: { current: DocLocation }) {
const overview = current.package.pages.find(
(page) => page.section === "overview"
)
const groups = current.package.pages
.filter((page) => page.section !== "overview")
.reduce<Array<{ section: DocPage["section"]; pages: DocPage[] }>>(
(result, page) => {
const group = result.at(-1)
if (group?.section === page.section) {
group.pages.push(page)
} else {
result.push({ section: page.section, pages: [page] })
}
return result
},
[]
)
return (
<ul>
{overview && (
<li>
<SidebarPageLink current={current} page={overview} prominent />
</li>
)}
{groups.map((group) => (
<li className="not-first:mt-8" key={group.section}>
<h2 className="font-semibold">
{getDocSectionLabel(group.section, current.locale)}
</h2>
<ul className="mt-1.5">
{group.pages.map((page) => (
<li key={`${page.section}/${page.slug}`}>
<SidebarPageLink current={current} page={page} />
</li>
))}
</ul>
</li>
))}
</ul>
)
}
function SidebarPageLink({
current,
page,
prominent = false,
}: {
current: DocLocation
page: DocPage
prominent?: boolean
}) {
return (
<a
className={`block py-1 transition-colors hover:text-indigo-500 aria-[current=page]:text-indigo-500 dark:hover:text-indigo-300 dark:aria-[current=page]:text-indigo-400 ${prominent ? "font-semibold text-slate-950 dark:text-slate-100" : "text-gray-600 aria-[current=page]:font-medium dark:text-slate-400"}`}
href={getDocHref(current.package, page)}
aria-current={
current.page.section === page.section && current.page.slug === page.slug
? "page"
: undefined
}
>
{page.title}
</a>
)
}
function Header({ docLocation }: { docLocation: DocLocation }) {
return (
<header className="mb-12 border-b pb-12">
<ul className="flex items-center gap-2">
<li>
<a
href={getDocHref(docLocation.package)}
className="transition-colors hover:text-indigo-500 dark:hover:text-indigo-300"
>
{docLocation.package.label}
</a>
</li>
<li>
<span className="text-slate-200 dark:text-slate-700">/</span>
</li>
{docLocation.page.section !== "overview" && (
<>
<li>
<span className="text-slate-500 dark:text-slate-400">
{getDocSectionLabel(
docLocation.page.section,
docLocation.locale
)}
</span>
</li>
<li>
<span className="text-slate-200 dark:text-slate-700">/</span>
</li>
</>
)}
<li>
<span className="text-indigo-500 dark:text-indigo-400">
{docLocation.page.title}
</span>
</li>
</ul>
<h1 className="mt-4 text-4xl">{docLocation.page.title}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{docLocation.page.description}
</p>
</header>
)
}
function ArticleContent({ page }: { page: DocPage }) {
const Content = page.Content
return (
<article
data-article-content
className="space-y-5 text-slate-700 dark:text-slate-300 [&_hr]:my-12 [&_hr]:border-slate-200 dark:[&_hr]:border-slate-800 [&_li]:leading-7 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:leading-7 [&_strong]:font-semibold [&_strong]:text-slate-950 dark:[&_strong]:text-slate-100 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:p-3 dark:[&_td]:border-slate-800 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:p-3 [&_th]:text-left dark:[&_th]:border-slate-800 dark:[&_th]:bg-slate-900 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
>
<Content components={mdxComponents} />
</article>
)
}
function TableOfContents({
activeIds,
items,
}: {
activeIds: readonly string[]
items: readonly DocTocItem[]
}) {
const t = useTranslate()
const label = t({ zh: "本页内容", us: "On this page" })
const rootRef = React.useRef<HTMLElement>(null)
const railRef = React.useRef<HTMLDivElement>(null)
const [marker, setMarker] = React.useState<{
height: number
top: number
} | null>(null)
const activeKey = activeIds.join("\n")
React.useLayoutEffect(() => {
const root = rootRef.current
const rail = railRef.current
if (!root || !rail) return
function measureMarker() {
const activeIdSet = new Set(activeIds)
const activeRows = Array.from(
root!.querySelectorAll<HTMLElement>("[data-toc-id]")
).filter((row) => activeIdSet.has(row.dataset.tocId ?? ""))
if (activeRows.length === 0 || rail!.offsetHeight === 0) {
setMarker(null)
return
}
const railRect = rail!.getBoundingClientRect()
const firstRect = activeRows[0]!.getBoundingClientRect()
const lastRect = activeRows.at(-1)!.getBoundingClientRect()
setMarker({
top: firstRect.top - railRect.top,
height: lastRect.bottom - firstRect.top,
})
}
measureMarker()
const resizeObserver = new ResizeObserver(measureMarker)
resizeObserver.observe(root)
window.addEventListener("resize", measureMarker)
return () => {
resizeObserver.disconnect()
window.removeEventListener("resize", measureMarker)
}
}, [activeKey, activeIds])
return (
<aside
ref={rootRef}
aria-label={label}
className="relative max-h-full overflow-y-auto p-8"
>
<div
ref={railRef}
className="absolute top-8 bottom-6 left-8 hidden w-px bg-slate-200 md:block dark:bg-slate-800"
>
<div
className="absolute left-0 w-px bg-indigo-500 transition-[height,transform,opacity] duration-200 dark:bg-indigo-400"
style={{
height: marker?.height ?? 0,
opacity: marker ? 1 : 0,
transform: `translateY(${marker?.top ?? 0}px)`,
}}
/>
</div>
<div className="md:ps-4">
<h2 className="shrink-0">{label}</h2>
<ul className="mt-2">
{items.map((item) => (
<TocItem activeIds={activeIds} item={item} key={item.id} />
))}
</ul>
</div>
</aside>
)
}
function TocItem({
activeIds,
item,
}: {
activeIds: readonly string[]
item: DocTocItem
}) {
const active = activeIds.includes(item.id)
return (
<li>
<TocAnchor
active={active}
href={`#${item.id}`}
itemId={item.id}
title={item.title}
/>
{item.items && item.items.length > 0 && (
<ul className="ps-4">
{item.items.map((child) => (
<TocItem activeIds={activeIds} item={child} key={child.id} />
))}
</ul>
)}
</li>
)
}
function TocAnchor({
active,
href,
itemId,
title,
}: {
active: boolean
href: string
itemId: string
title: string
}) {
return (
<div className="py-1" data-toc-id={itemId}>
<a
href={href}
data-active={active || undefined}
className="text-slate-600 transition-colors hover:text-indigo-500 data-active:font-medium data-active:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 dark:data-active:text-indigo-400"
>
{title}
</a>
</div>
)
}
type TocVisibilityEntry = {
id: string
parentIds: readonly string[]
}
function useVisibleTocIds(items: readonly DocTocItem[]) {
const entries = React.useMemo(() => flattenTocItems(items), [items])
const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
React.useEffect(() => {
if (entries.length === 0) {
setActiveIds([])
return
}
let animationFrame: number | undefined
const articleElement = document.querySelector<HTMLElement>(
"[data-article-content]"
)
if (!articleElement) return
const article = articleElement
function update() {
animationFrame = undefined
const headings = entries.flatMap((entry) => {
const element = document.getElementById(entry.id)
return element ? [{ element, entry }] : []
})
const viewportTop = getVisibleViewportTop()
const viewportBottom = window.innerHeight
const articleBottom = article.getBoundingClientRect().bottom
const visibleIds = new Set<string>()
headings.forEach(({ element, entry }, index) => {
const sectionTop = element.getBoundingClientRect().top
const sectionBottom =
headings[index + 1]?.element.getBoundingClientRect().top ??
articleBottom
if (sectionBottom > viewportTop && sectionTop < viewportBottom) {
entry.parentIds.forEach((id) => visibleIds.add(id))
visibleIds.add(entry.id)
}
})
const nextActiveIds = entries
.map((entry) => entry.id)
.filter((id) => visibleIds.has(id))
setActiveIds((current) =>
arraysEqual(current, nextActiveIds) ? current : nextActiveIds
)
}
function scheduleUpdate() {
if (animationFrame !== undefined) return
animationFrame = window.requestAnimationFrame(update)
}
scheduleUpdate()
const resizeObserver = new ResizeObserver(scheduleUpdate)
resizeObserver.observe(article)
window.addEventListener("scroll", scheduleUpdate, { passive: true })
window.addEventListener("resize", scheduleUpdate)
return () => {
if (animationFrame !== undefined) {
window.cancelAnimationFrame(animationFrame)
}
resizeObserver.disconnect()
window.removeEventListener("scroll", scheduleUpdate)
window.removeEventListener("resize", scheduleUpdate)
}
}, [entries])
return activeIds
}
function flattenTocItems(
items: readonly DocTocItem[],
parentIds: readonly string[] = []
): readonly TocVisibilityEntry[] {
return items.flatMap((item) => [
{ id: item.id, parentIds },
...flattenTocItems(item.items ?? [], [...parentIds, item.id]),
])
}
function getVisibleViewportTop() {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-article-sticky-header]")
).reduce((bottom, element) => {
const rect = element.getBoundingClientRect()
return rect.top <= 0 && rect.bottom > 0
? Math.max(bottom, rect.bottom)
: bottom
}, 0)
}
function arraysEqual(left: readonly string[], right: readonly string[]) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
)
}
function Footer({
next,
previous,
}: {
next?: DocLocation
previous?: DocLocation
}) {
return (
<footer className="mt-12 flex flex-col gap-4 border-t border-dashed pt-12 md:grid md:grid-cols-2 md:gap-8">
{previous && (
<Surround
href={getDocHref(previous.package, previous.page)}
title={previous.page.title}
description={previous.page.description}
align="left"
/>
)}
{next && (
<Surround
href={getDocHref(next.package, next.page)}
title={next.page.title}
description={next.page.description}
align="right"
/>
)}
</footer>
)
}
function Surround({
href,
title,
description,
align = "right",
}: {
title: string
description: string
align?: "left" | "right"
href: string
}) {
return (
<a
href={href}
className={[
"group flex min-w-0 flex-col gap-1 rounded-xl border p-4",
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900",
align === "left"
? "col-start-1 items-start text-left"
: "col-start-2 items-end text-right",
].join(" ")}
>
<div className="flex size-8 items-center justify-center rounded-full border bg-slate-50 text-sm font-bold transition-colors group-hover:border-indigo-500 group-hover:bg-indigo-100 group-hover:text-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:group-hover:border-indigo-400 dark:group-hover:bg-indigo-950/70 dark:group-hover:text-indigo-300">
{align === "left" ? (
<Icon name="tabler:arrow-left" />
) : (
<Icon name="tabler:arrow-right" />
)}
</div>
<h2 className="mt-4 w-full min-w-0 truncate text-lg">{title}</h2>
<p className="text-md mt-1 line-clamp-2 w-full min-w-0 break-all text-slate-500 dark:text-slate-400">
{description}
</p>
</a>
)
}
function ArticleNotFound() {
const locale = useLocale()
const t = useTranslate()
return (
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
<h1 className="text-4xl">
{t({ zh: "未找到文档", us: "Document not found" })}
</h1>
<p className="mt-3 text-slate-500 dark:text-slate-400">
{t({
zh: "请求的文档包或页面不存在。",
us: "The requested package or document does not exist.",
})}
</p>
<a
className="mt-6 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
href={getDocHref(getDocPackages(locale)[0]!)}
>
{t({ zh: "打开文档", us: "Open the documentation" })}
</a>
</main>
)
}