@assistant-ui/docs-theme 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -5
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/site/constants.d.ts +2 -0
- package/dist/site/constants.d.ts.map +1 -0
- package/dist/site/constants.js +5 -0
- package/dist/site/constants.js.map +1 -0
- package/dist/site/footer.d.ts +13 -0
- package/dist/site/footer.d.ts.map +1 -0
- package/dist/site/footer.js +35 -0
- package/dist/site/footer.js.map +1 -0
- package/dist/site/header.d.ts +13 -0
- package/dist/site/header.d.ts.map +1 -0
- package/dist/site/header.js +81 -0
- package/dist/site/header.js.map +1 -0
- package/dist/site/layout.d.ts +8 -0
- package/dist/site/layout.d.ts.map +1 -0
- package/dist/site/layout.js +21 -0
- package/dist/site/layout.js.map +1 -0
- package/dist/site/page.d.ts +9 -0
- package/dist/site/page.d.ts.map +1 -0
- package/dist/site/page.js +32 -0
- package/dist/site/page.js.map +1 -0
- package/dist/site/pager.d.ts +13 -0
- package/dist/site/pager.d.ts.map +1 -0
- package/dist/site/pager.js +36 -0
- package/dist/site/pager.js.map +1 -0
- package/dist/site/sidebar-navigation.d.ts +13 -0
- package/dist/site/sidebar-navigation.d.ts.map +1 -0
- package/dist/site/sidebar-navigation.js +185 -0
- package/dist/site/sidebar-navigation.js.map +1 -0
- package/dist/site/sidebar.d.ts +16 -0
- package/dist/site/sidebar.d.ts.map +1 -0
- package/dist/site/sidebar.js +46 -0
- package/dist/site/sidebar.js.map +1 -0
- package/dist/site/toc.d.ts +12 -0
- package/dist/site/toc.d.ts.map +1 -0
- package/dist/site/toc.js +92 -0
- package/dist/site/toc.js.map +1 -0
- package/dist/site/tree.d.ts +24 -0
- package/dist/site/tree.d.ts.map +1 -0
- package/dist/site/tree.js +14 -0
- package/dist/site/tree.js.map +1 -0
- package/dist/site/utils.d.ts +2 -0
- package/dist/site/utils.d.ts.map +1 -0
- package/dist/site/utils.js +5 -0
- package/dist/site/utils.js.map +1 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +9 -0
- package/package.json +21 -11
- package/src/config.ts +1 -1
- package/src/index.ts +1 -1
- package/src/site/constants.ts +1 -0
- package/src/site/footer.tsx +59 -0
- package/src/site/header.test.tsx +89 -0
- package/src/site/header.tsx +117 -0
- package/src/site/layout.tsx +33 -0
- package/src/site/page.tsx +38 -0
- package/src/site/pager.tsx +54 -0
- package/src/site/sidebar-navigation.test.tsx +92 -0
- package/src/site/sidebar-navigation.tsx +267 -0
- package/src/site/sidebar.tsx +77 -0
- package/src/site/toc.test.tsx +187 -0
- package/src/site/toc.tsx +118 -0
- package/src/site/tree.ts +36 -0
- package/src/site/utils.ts +2 -0
- package/src/ui.ts +19 -0
- package/styles/site.css +223 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import type { DocsNode, DocsFolder } from "./tree";
|
|
6
|
+
import { findActivePath } from "./tree";
|
|
7
|
+
import { ChevronDown } from "lucide-react";
|
|
8
|
+
import { AnimatePresence, domAnimation, LazyMotion } from "motion/react";
|
|
9
|
+
import * as m from "motion/react-m";
|
|
10
|
+
import { cn } from "./utils";
|
|
11
|
+
|
|
12
|
+
function SectionItem({
|
|
13
|
+
item,
|
|
14
|
+
onNavigate,
|
|
15
|
+
pathname,
|
|
16
|
+
activePath,
|
|
17
|
+
isVisible,
|
|
18
|
+
}: {
|
|
19
|
+
item: DocsNode;
|
|
20
|
+
onNavigate: () => void;
|
|
21
|
+
pathname: string;
|
|
22
|
+
activePath: ReadonlySet<DocsNode>;
|
|
23
|
+
isVisible: (node: DocsNode) => boolean;
|
|
24
|
+
}) {
|
|
25
|
+
if (!isVisible(item)) return null;
|
|
26
|
+
|
|
27
|
+
if (item.type === "separator") {
|
|
28
|
+
return (
|
|
29
|
+
<p className="text-muted-foreground mt-4 mb-1 px-2 text-xs font-medium first:mt-1">
|
|
30
|
+
{item.name}
|
|
31
|
+
</p>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (item.type === "folder") {
|
|
36
|
+
const isActive = item.index && pathname === item.index.url;
|
|
37
|
+
const containsActive = activePath.has(item);
|
|
38
|
+
const hasChildren = item.children.length > 0;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div>
|
|
42
|
+
{item.index ? (
|
|
43
|
+
<Link
|
|
44
|
+
href={item.index.url}
|
|
45
|
+
onClick={onNavigate}
|
|
46
|
+
data-active={isActive ? "true" : "false"}
|
|
47
|
+
className={cn(
|
|
48
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors duration-150",
|
|
49
|
+
isActive
|
|
50
|
+
? "bg-accent/20 text-foreground dark:bg-accent/50 font-medium"
|
|
51
|
+
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground/90 dark:hover:bg-accent/40",
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{item.icon}
|
|
55
|
+
<span className="truncate">{item.name}</span>
|
|
56
|
+
</Link>
|
|
57
|
+
) : (
|
|
58
|
+
<p className="text-muted-foreground mt-3 mb-1 flex items-center gap-2 px-2 text-xs font-medium first:mt-1">
|
|
59
|
+
{item.icon}
|
|
60
|
+
{item.name}
|
|
61
|
+
</p>
|
|
62
|
+
)}
|
|
63
|
+
{hasChildren && (containsActive || !item.index) && (
|
|
64
|
+
<div className="border-border/50 ml-2 flex flex-col gap-0.5 border-l pl-2">
|
|
65
|
+
{item.children.map((child) => (
|
|
66
|
+
<SectionItem
|
|
67
|
+
key={child.$id}
|
|
68
|
+
item={child}
|
|
69
|
+
onNavigate={onNavigate}
|
|
70
|
+
pathname={pathname}
|
|
71
|
+
activePath={activePath}
|
|
72
|
+
isVisible={isVisible}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isActive = pathname === item.url;
|
|
82
|
+
return (
|
|
83
|
+
<Link
|
|
84
|
+
href={item.url}
|
|
85
|
+
onClick={onNavigate}
|
|
86
|
+
data-active={isActive ? "true" : "false"}
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors duration-150",
|
|
89
|
+
isActive
|
|
90
|
+
? "bg-accent/20 text-foreground dark:bg-accent/50 font-medium"
|
|
91
|
+
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground/90 dark:hover:bg-accent/40",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{item.icon}
|
|
95
|
+
<span className="truncate">{item.name}</span>
|
|
96
|
+
</Link>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function SidebarSection({
|
|
101
|
+
folder,
|
|
102
|
+
isOpen,
|
|
103
|
+
onToggle,
|
|
104
|
+
onNavigate,
|
|
105
|
+
pathname,
|
|
106
|
+
activePath,
|
|
107
|
+
isVisible,
|
|
108
|
+
}: {
|
|
109
|
+
folder: DocsFolder;
|
|
110
|
+
isOpen: boolean;
|
|
111
|
+
onToggle: () => void;
|
|
112
|
+
onNavigate: () => void;
|
|
113
|
+
pathname: string;
|
|
114
|
+
activePath: ReadonlySet<DocsNode>;
|
|
115
|
+
isVisible: (node: DocsNode) => boolean;
|
|
116
|
+
}) {
|
|
117
|
+
const isActive = folder.index && pathname === folder.index.url;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onToggle}
|
|
124
|
+
aria-expanded={isOpen}
|
|
125
|
+
className={cn(
|
|
126
|
+
"hover:bg-accent/30 dark:hover:bg-accent/40 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-[13px] transition-colors duration-150",
|
|
127
|
+
isActive || isOpen
|
|
128
|
+
? "text-foreground font-medium"
|
|
129
|
+
: "text-muted-foreground/90",
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<span className="text-muted-foreground flex size-4 shrink-0 items-center justify-center">
|
|
133
|
+
{folder.icon}
|
|
134
|
+
</span>
|
|
135
|
+
<span className="flex-1 truncate">{folder.name}</span>
|
|
136
|
+
<ChevronDown
|
|
137
|
+
className={cn(
|
|
138
|
+
"text-muted-foreground/60 size-3.5 shrink-0 transition-transform duration-200",
|
|
139
|
+
!isOpen && "-rotate-90",
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
</button>
|
|
143
|
+
<AnimatePresence initial={false}>
|
|
144
|
+
{isOpen && (
|
|
145
|
+
<m.div
|
|
146
|
+
initial={{ height: 0, opacity: 0 }}
|
|
147
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
148
|
+
exit={{ height: 0, opacity: 0 }}
|
|
149
|
+
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
|
150
|
+
className="overflow-hidden"
|
|
151
|
+
>
|
|
152
|
+
<div className="border-border/50 mt-0.5 mb-1 ml-4 flex flex-col gap-0.5 border-l pl-2">
|
|
153
|
+
{folder.children.map((child) => (
|
|
154
|
+
<SectionItem
|
|
155
|
+
key={child.$id}
|
|
156
|
+
item={child}
|
|
157
|
+
onNavigate={onNavigate}
|
|
158
|
+
pathname={pathname}
|
|
159
|
+
activePath={activePath}
|
|
160
|
+
isVisible={isVisible}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</m.div>
|
|
165
|
+
)}
|
|
166
|
+
</AnimatePresence>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const visible = () => true;
|
|
172
|
+
|
|
173
|
+
export function DocsSidebarNavigation({
|
|
174
|
+
sections,
|
|
175
|
+
pathname,
|
|
176
|
+
activeSectionId,
|
|
177
|
+
isVisible = visible,
|
|
178
|
+
onNavigate,
|
|
179
|
+
banner,
|
|
180
|
+
footer,
|
|
181
|
+
platform,
|
|
182
|
+
}: {
|
|
183
|
+
sections: readonly DocsFolder[];
|
|
184
|
+
pathname: string;
|
|
185
|
+
activeSectionId: string | null;
|
|
186
|
+
isVisible?: (node: DocsNode) => boolean;
|
|
187
|
+
onNavigate: () => void;
|
|
188
|
+
banner?: ReactNode;
|
|
189
|
+
footer?: ReactNode;
|
|
190
|
+
platform?: string;
|
|
191
|
+
}) {
|
|
192
|
+
const navRef = useRef<HTMLElement>(null);
|
|
193
|
+
const activePath = useMemo(
|
|
194
|
+
() => new Set(findActivePath(sections, pathname)),
|
|
195
|
+
[sections, pathname],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const [openSectionId, setOpenSectionId] = useState<string | null>(
|
|
199
|
+
activeSectionId,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const [sectionScope, setSectionScope] = useState({
|
|
203
|
+
activeSectionId,
|
|
204
|
+
pathname,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
sectionScope.activeSectionId !== activeSectionId ||
|
|
209
|
+
sectionScope.pathname !== pathname
|
|
210
|
+
) {
|
|
211
|
+
setSectionScope({ activeSectionId, pathname });
|
|
212
|
+
if (activeSectionId) setOpenSectionId(activeSectionId);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (openSectionId !== activeSectionId) return;
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
const nav = navRef.current;
|
|
219
|
+
if (!nav) return;
|
|
220
|
+
const active = nav.querySelector<HTMLElement>("[data-active='true']");
|
|
221
|
+
if (!active) return;
|
|
222
|
+
const navRect = nav.getBoundingClientRect();
|
|
223
|
+
const elRect = active.getBoundingClientRect();
|
|
224
|
+
if (elRect.top < navRect.top || elRect.bottom > navRect.bottom) {
|
|
225
|
+
active.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
226
|
+
}
|
|
227
|
+
}, 260);
|
|
228
|
+
return () => clearTimeout(timer);
|
|
229
|
+
}, [pathname, openSectionId, activeSectionId]);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className="flex h-full flex-col">
|
|
233
|
+
{banner && <div className="shrink-0 px-3 pt-4 lg:hidden">{banner}</div>}
|
|
234
|
+
<nav
|
|
235
|
+
aria-label="Docs"
|
|
236
|
+
ref={navRef}
|
|
237
|
+
data-docs-platform={platform}
|
|
238
|
+
className={cn(
|
|
239
|
+
"sidebar-tree-content flex min-h-0 flex-1 flex-col gap-0.5 overflow-x-hidden overflow-y-auto px-3 pt-2 pb-4 lg:pt-4",
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
<LazyMotion features={domAnimation}>
|
|
243
|
+
{sections.map((section) => (
|
|
244
|
+
<SidebarSection
|
|
245
|
+
key={section.$id}
|
|
246
|
+
folder={section}
|
|
247
|
+
isOpen={openSectionId === section.$id}
|
|
248
|
+
onToggle={() => {
|
|
249
|
+
const id = section.$id ?? null;
|
|
250
|
+
setOpenSectionId(openSectionId !== id ? id : null);
|
|
251
|
+
}}
|
|
252
|
+
onNavigate={onNavigate}
|
|
253
|
+
pathname={pathname}
|
|
254
|
+
activePath={activePath}
|
|
255
|
+
isVisible={isVisible}
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</LazyMotion>
|
|
259
|
+
</nav>
|
|
260
|
+
{footer && (
|
|
261
|
+
<div className="border-border/50 flex shrink-0 items-center gap-1 border-t px-3 py-2">
|
|
262
|
+
{footer}
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { cn } from "./utils";
|
|
12
|
+
import { DOCS_SIDEBAR_WIDTH } from "./constants";
|
|
13
|
+
|
|
14
|
+
export const DOCS_SIDEBAR_ID = "docs-sidebar";
|
|
15
|
+
|
|
16
|
+
const subscribeToViewport = (onChange: () => void) => {
|
|
17
|
+
const viewport = window.matchMedia("(max-width: 767px)");
|
|
18
|
+
viewport.addEventListener("change", onChange);
|
|
19
|
+
return () => viewport.removeEventListener("change", onChange);
|
|
20
|
+
};
|
|
21
|
+
const isMobileViewport = () => window.innerWidth < 768;
|
|
22
|
+
const serverViewport = () => false;
|
|
23
|
+
|
|
24
|
+
interface DocsSidebarContextValue {
|
|
25
|
+
open: boolean;
|
|
26
|
+
setOpen: (open: boolean) => void;
|
|
27
|
+
toggle: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DocsSidebarContext = createContext<DocsSidebarContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
export function useDocsSidebar() {
|
|
33
|
+
const ctx = useContext(DocsSidebarContext);
|
|
34
|
+
if (!ctx) {
|
|
35
|
+
throw new Error("useDocsSidebar must be used within DocsSidebarProvider");
|
|
36
|
+
}
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DocsSidebarProvider({ children }: { children: ReactNode }) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const toggle = useCallback(() => setOpen((prev) => !prev), []);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<DocsSidebarContext.Provider value={{ open, setOpen, toggle }}>
|
|
46
|
+
{children}
|
|
47
|
+
</DocsSidebarContext.Provider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function DocsSidebar({ children }: { children: ReactNode }) {
|
|
52
|
+
const { open } = useDocsSidebar();
|
|
53
|
+
const isMobile = useSyncExternalStore(
|
|
54
|
+
subscribeToViewport,
|
|
55
|
+
isMobileViewport,
|
|
56
|
+
serverViewport,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<aside
|
|
61
|
+
id={DOCS_SIDEBAR_ID}
|
|
62
|
+
inert={!open && isMobile}
|
|
63
|
+
className={cn(
|
|
64
|
+
"bg-background fixed inset-x-0 top-12 bottom-0 z-40 transition-[opacity,visibility] duration-200",
|
|
65
|
+
open ? "opacity-100" : "pointer-events-none opacity-0 max-md:invisible",
|
|
66
|
+
"md:pointer-events-auto md:visible md:right-auto md:left-0 md:z-30 md:w-(--sidebar-width) md:opacity-100",
|
|
67
|
+
)}
|
|
68
|
+
style={
|
|
69
|
+
{
|
|
70
|
+
"--sidebar-width": `${DOCS_SIDEBAR_WIDTH}px`,
|
|
71
|
+
} as React.CSSProperties
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
</aside>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { DocsTableOfContents } from "./toc";
|
|
7
|
+
|
|
8
|
+
it("observes the current page's headings and releases them when the page changes", async () => {
|
|
9
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
|
+
const viewport = {
|
|
11
|
+
matches: true,
|
|
12
|
+
addEventListener: vi.fn(),
|
|
13
|
+
removeEventListener: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
vi.stubGlobal("matchMedia", () => viewport);
|
|
16
|
+
const observers: {
|
|
17
|
+
observe: ReturnType<typeof vi.fn>;
|
|
18
|
+
disconnect: ReturnType<typeof vi.fn>;
|
|
19
|
+
}[] = [];
|
|
20
|
+
vi.stubGlobal(
|
|
21
|
+
"IntersectionObserver",
|
|
22
|
+
class {
|
|
23
|
+
observe = vi.fn();
|
|
24
|
+
disconnect = vi.fn();
|
|
25
|
+
constructor() {
|
|
26
|
+
observers.push(this);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
const container = document.createElement("div");
|
|
31
|
+
document.body.append(container);
|
|
32
|
+
const root = createRoot(container);
|
|
33
|
+
const render = (id: string) =>
|
|
34
|
+
act(async () =>
|
|
35
|
+
root.render(
|
|
36
|
+
<>
|
|
37
|
+
<h2 id={id}>Current heading</h2>
|
|
38
|
+
<DocsTableOfContents
|
|
39
|
+
items={[{ title: "Current heading", url: `#${id}`, depth: 2 }]}
|
|
40
|
+
>
|
|
41
|
+
<button>Copy page</button>
|
|
42
|
+
</DocsTableOfContents>
|
|
43
|
+
</>,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
try {
|
|
47
|
+
await render("install");
|
|
48
|
+
expect(container.querySelector('a[href="#install"]')?.textContent).toBe(
|
|
49
|
+
"Current heading",
|
|
50
|
+
);
|
|
51
|
+
expect(observers[0]!.observe).toHaveBeenCalledWith(
|
|
52
|
+
container.querySelector("h2"),
|
|
53
|
+
);
|
|
54
|
+
await render("usage");
|
|
55
|
+
expect(observers[0]!.disconnect).toHaveBeenCalledOnce();
|
|
56
|
+
expect(observers[1]!.observe).toHaveBeenCalledWith(
|
|
57
|
+
container.querySelector("h2"),
|
|
58
|
+
);
|
|
59
|
+
expect(container.querySelector('a[href="#install"]')).toBeNull();
|
|
60
|
+
expect(container.querySelector("button")?.textContent).toBe("Copy page");
|
|
61
|
+
await act(async () =>
|
|
62
|
+
root.render(
|
|
63
|
+
<DocsTableOfContents items={[]}>
|
|
64
|
+
<button>Copy page</button>
|
|
65
|
+
</DocsTableOfContents>,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
expect(observers[1]!.disconnect).toHaveBeenCalledOnce();
|
|
69
|
+
expect(container.querySelector("button")).toBeNull();
|
|
70
|
+
} finally {
|
|
71
|
+
await act(async () => root.unmount());
|
|
72
|
+
container.remove();
|
|
73
|
+
vi.unstubAllGlobals();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does no heading observation while the TOC is hidden and resumes when it becomes visible", async () => {
|
|
78
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
79
|
+
let onChange = () => {};
|
|
80
|
+
const viewport = {
|
|
81
|
+
matches: false,
|
|
82
|
+
addEventListener: (_: string, listener: () => void) => {
|
|
83
|
+
onChange = listener;
|
|
84
|
+
},
|
|
85
|
+
removeEventListener: vi.fn(),
|
|
86
|
+
};
|
|
87
|
+
vi.stubGlobal("matchMedia", () => viewport);
|
|
88
|
+
const observe = vi.fn();
|
|
89
|
+
const disconnect = vi.fn();
|
|
90
|
+
const Observer = vi.fn(function () {
|
|
91
|
+
return { observe, disconnect };
|
|
92
|
+
});
|
|
93
|
+
vi.stubGlobal("IntersectionObserver", Observer);
|
|
94
|
+
const container = document.createElement("div");
|
|
95
|
+
document.body.append(container);
|
|
96
|
+
const root = createRoot(container);
|
|
97
|
+
try {
|
|
98
|
+
await act(async () =>
|
|
99
|
+
root.render(
|
|
100
|
+
<>
|
|
101
|
+
<h2 id="setup">Setup</h2>
|
|
102
|
+
<DocsTableOfContents
|
|
103
|
+
items={[{ title: "Setup", url: "#setup", depth: 2 }]}
|
|
104
|
+
/>
|
|
105
|
+
</>,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
expect(Observer).not.toHaveBeenCalled();
|
|
109
|
+
viewport.matches = true;
|
|
110
|
+
await act(async () => onChange());
|
|
111
|
+
expect(Observer).toHaveBeenCalledOnce();
|
|
112
|
+
expect(observe).toHaveBeenCalledWith(container.querySelector("h2"));
|
|
113
|
+
viewport.matches = false;
|
|
114
|
+
await act(async () => onChange());
|
|
115
|
+
expect(disconnect).toHaveBeenCalledOnce();
|
|
116
|
+
} finally {
|
|
117
|
+
await act(async () => root.unmount());
|
|
118
|
+
expect(viewport.removeEventListener).toHaveBeenCalledWith(
|
|
119
|
+
"change",
|
|
120
|
+
onChange,
|
|
121
|
+
);
|
|
122
|
+
container.remove();
|
|
123
|
+
vi.unstubAllGlobals();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each([
|
|
128
|
+
["café-配置", "#caf%C3%A9-%E9%85%8D%E7%BD%AE"],
|
|
129
|
+
['quoted"id', "#quoted%22id"],
|
|
130
|
+
["literal%id", "#literal%25id"],
|
|
131
|
+
])(
|
|
132
|
+
"tracks the rendered heading %s from an encoded fragment",
|
|
133
|
+
async (id, url) => {
|
|
134
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
135
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
136
|
+
matches: true,
|
|
137
|
+
addEventListener() {},
|
|
138
|
+
removeEventListener() {},
|
|
139
|
+
}));
|
|
140
|
+
const observe = vi.fn();
|
|
141
|
+
let notify: (
|
|
142
|
+
entries: { isIntersecting: boolean; target: Element }[],
|
|
143
|
+
) => void;
|
|
144
|
+
vi.stubGlobal(
|
|
145
|
+
"IntersectionObserver",
|
|
146
|
+
class {
|
|
147
|
+
observe = observe;
|
|
148
|
+
disconnect() {}
|
|
149
|
+
constructor(callback: typeof notify) {
|
|
150
|
+
notify = callback;
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
const container = document.createElement("div");
|
|
155
|
+
document.body.append(container);
|
|
156
|
+
const root = createRoot(container);
|
|
157
|
+
try {
|
|
158
|
+
await act(async () =>
|
|
159
|
+
root.render(
|
|
160
|
+
<>
|
|
161
|
+
<h2 id={id}>Current heading</h2>
|
|
162
|
+
<DocsTableOfContents
|
|
163
|
+
items={[{ title: "Current heading", url, depth: 2 }]}
|
|
164
|
+
/>
|
|
165
|
+
</>,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
const heading = container.querySelector("h2")!;
|
|
169
|
+
expect(observe).toHaveBeenCalledWith(heading);
|
|
170
|
+
const item = container.querySelector("li")!;
|
|
171
|
+
const scrollIntoView = vi.fn();
|
|
172
|
+
item.scrollIntoView = scrollIntoView;
|
|
173
|
+
await act(async () =>
|
|
174
|
+
notify([{ isIntersecting: true, target: heading }]),
|
|
175
|
+
);
|
|
176
|
+
expect(scrollIntoView).toHaveBeenCalledWith({
|
|
177
|
+
behavior: "smooth",
|
|
178
|
+
block: "nearest",
|
|
179
|
+
});
|
|
180
|
+
expect(container.querySelector("a")?.getAttribute("href")).toBe(url);
|
|
181
|
+
} finally {
|
|
182
|
+
await act(async () => root.unmount());
|
|
183
|
+
container.remove();
|
|
184
|
+
vi.unstubAllGlobals();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
package/src/site/toc.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
type TOCItem = {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
url: string;
|
|
9
|
+
depth: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function headingId(url: string) {
|
|
13
|
+
const fragment = url.slice(1);
|
|
14
|
+
try {
|
|
15
|
+
return decodeURIComponent(fragment);
|
|
16
|
+
} catch {
|
|
17
|
+
return fragment;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function DocsTableOfContents({
|
|
22
|
+
items,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
items: TOCItem[];
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
}) {
|
|
28
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
29
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (items.length === 0) return;
|
|
33
|
+
|
|
34
|
+
const headingIds = items.map((item) => headingId(item.url));
|
|
35
|
+
|
|
36
|
+
const viewport = window.matchMedia("(min-width: 1280px)");
|
|
37
|
+
let observer: IntersectionObserver | undefined;
|
|
38
|
+
const observeVisibleHeadings = () => {
|
|
39
|
+
observer?.disconnect();
|
|
40
|
+
if (!viewport.matches) return;
|
|
41
|
+
observer = new IntersectionObserver(
|
|
42
|
+
(entries) => {
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isIntersecting) {
|
|
45
|
+
setActiveId(entry.target.id);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 },
|
|
51
|
+
);
|
|
52
|
+
for (const id of headingIds) {
|
|
53
|
+
const element = document.getElementById(id);
|
|
54
|
+
if (element) observer.observe(element);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
observeVisibleHeadings();
|
|
58
|
+
viewport.addEventListener("change", observeVisibleHeadings);
|
|
59
|
+
return () => {
|
|
60
|
+
observer?.disconnect();
|
|
61
|
+
viewport.removeEventListener("change", observeVisibleHeadings);
|
|
62
|
+
};
|
|
63
|
+
}, [items]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!activeId || !listRef.current) return;
|
|
67
|
+
|
|
68
|
+
const activeElement = Array.from(listRef.current.children).find(
|
|
69
|
+
(item) => item.getAttribute("data-toc-id") === activeId,
|
|
70
|
+
);
|
|
71
|
+
if (activeElement) {
|
|
72
|
+
activeElement.scrollIntoView({
|
|
73
|
+
behavior: "smooth",
|
|
74
|
+
block: "nearest",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}, [activeId]);
|
|
78
|
+
|
|
79
|
+
if (items.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="docs-toc w-56 max-xl:hidden">
|
|
83
|
+
<div className="sticky top-[calc(var(--docs-header-height)_+_1rem)] flex max-h-[calc(100vh_-_var(--docs-header-height)_-_1rem)] flex-col pe-4 pt-4 pb-2">
|
|
84
|
+
<p className="text-muted-foreground mb-3 shrink-0 text-xs">
|
|
85
|
+
On this page
|
|
86
|
+
</p>
|
|
87
|
+
<ul
|
|
88
|
+
ref={listRef}
|
|
89
|
+
className="flex min-h-0 flex-1 [scrollbar-width:none] flex-col gap-1 overflow-x-hidden overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
|
90
|
+
>
|
|
91
|
+
{items.map((item) => {
|
|
92
|
+
const id = headingId(item.url);
|
|
93
|
+
const isActive = activeId === id;
|
|
94
|
+
const indent = Math.max(0, item.depth - 2) * 12;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<li key={item.url} data-toc-id={id}>
|
|
98
|
+
<a
|
|
99
|
+
href={item.url}
|
|
100
|
+
style={{ paddingLeft: indent || undefined }}
|
|
101
|
+
className={cn(
|
|
102
|
+
"block py-1 text-[13px] leading-snug wrap-break-word transition-colors",
|
|
103
|
+
isActive
|
|
104
|
+
? "text-foreground font-medium"
|
|
105
|
+
: "text-muted-foreground hover:text-foreground",
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{item.title}
|
|
109
|
+
</a>
|
|
110
|
+
</li>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</ul>
|
|
114
|
+
<div className="mt-6 shrink-0">{children}</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/site/tree.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface DocsNodeBase {
|
|
4
|
+
$id?: string;
|
|
5
|
+
name?: ReactNode;
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export interface DocsPage extends DocsNodeBase {
|
|
9
|
+
type: "page";
|
|
10
|
+
name: ReactNode;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DocsFolder extends DocsNodeBase {
|
|
14
|
+
type: "folder";
|
|
15
|
+
name: ReactNode;
|
|
16
|
+
index?: DocsPage;
|
|
17
|
+
children: DocsNode[];
|
|
18
|
+
}
|
|
19
|
+
export interface DocsSeparator extends DocsNodeBase {
|
|
20
|
+
type: "separator";
|
|
21
|
+
}
|
|
22
|
+
export type DocsNode = DocsPage | DocsFolder | DocsSeparator;
|
|
23
|
+
|
|
24
|
+
export function findActivePath(
|
|
25
|
+
nodes: readonly DocsNode[],
|
|
26
|
+
pathname: string,
|
|
27
|
+
): DocsNode[] | null {
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.type === "page" && node.url === pathname) return [node];
|
|
30
|
+
if (node.type !== "folder") continue;
|
|
31
|
+
if (node.index?.url === pathname) return [node, node.index];
|
|
32
|
+
const path = findActivePath(node.children, pathname);
|
|
33
|
+
if (path) return [node, ...path];
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { DocsShell, DocsContent } from "./site/layout";
|
|
2
|
+
export { DocsPageShell, DocsBody } from "./site/page";
|
|
3
|
+
export { DocsFooter } from "./site/footer";
|
|
4
|
+
export {
|
|
5
|
+
DOCS_SIDEBAR_ID,
|
|
6
|
+
DocsSidebar,
|
|
7
|
+
DocsSidebarProvider,
|
|
8
|
+
useDocsSidebar,
|
|
9
|
+
} from "./site/sidebar";
|
|
10
|
+
export { DocsSidebarNavigation } from "./site/sidebar-navigation";
|
|
11
|
+
export { DocsTableOfContents } from "./site/toc";
|
|
12
|
+
export type {
|
|
13
|
+
DocsNode,
|
|
14
|
+
DocsPage,
|
|
15
|
+
DocsFolder,
|
|
16
|
+
DocsSeparator,
|
|
17
|
+
} from "./site/tree";
|
|
18
|
+
export { DocsSiteHeader } from "./site/header";
|
|
19
|
+
export { DocsPagerNavigation } from "./site/pager";
|