@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.
Files changed (75) hide show
  1. package/README.md +89 -5
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/site/constants.d.ts +2 -0
  11. package/dist/site/constants.d.ts.map +1 -0
  12. package/dist/site/constants.js +5 -0
  13. package/dist/site/constants.js.map +1 -0
  14. package/dist/site/footer.d.ts +13 -0
  15. package/dist/site/footer.d.ts.map +1 -0
  16. package/dist/site/footer.js +35 -0
  17. package/dist/site/footer.js.map +1 -0
  18. package/dist/site/header.d.ts +13 -0
  19. package/dist/site/header.d.ts.map +1 -0
  20. package/dist/site/header.js +81 -0
  21. package/dist/site/header.js.map +1 -0
  22. package/dist/site/layout.d.ts +8 -0
  23. package/dist/site/layout.d.ts.map +1 -0
  24. package/dist/site/layout.js +21 -0
  25. package/dist/site/layout.js.map +1 -0
  26. package/dist/site/page.d.ts +9 -0
  27. package/dist/site/page.d.ts.map +1 -0
  28. package/dist/site/page.js +32 -0
  29. package/dist/site/page.js.map +1 -0
  30. package/dist/site/pager.d.ts +13 -0
  31. package/dist/site/pager.d.ts.map +1 -0
  32. package/dist/site/pager.js +36 -0
  33. package/dist/site/pager.js.map +1 -0
  34. package/dist/site/sidebar-navigation.d.ts +13 -0
  35. package/dist/site/sidebar-navigation.d.ts.map +1 -0
  36. package/dist/site/sidebar-navigation.js +185 -0
  37. package/dist/site/sidebar-navigation.js.map +1 -0
  38. package/dist/site/sidebar.d.ts +16 -0
  39. package/dist/site/sidebar.d.ts.map +1 -0
  40. package/dist/site/sidebar.js +46 -0
  41. package/dist/site/sidebar.js.map +1 -0
  42. package/dist/site/toc.d.ts +12 -0
  43. package/dist/site/toc.d.ts.map +1 -0
  44. package/dist/site/toc.js +92 -0
  45. package/dist/site/toc.js.map +1 -0
  46. package/dist/site/tree.d.ts +24 -0
  47. package/dist/site/tree.d.ts.map +1 -0
  48. package/dist/site/tree.js +14 -0
  49. package/dist/site/tree.js.map +1 -0
  50. package/dist/site/utils.d.ts +2 -0
  51. package/dist/site/utils.d.ts.map +1 -0
  52. package/dist/site/utils.js +5 -0
  53. package/dist/site/utils.js.map +1 -0
  54. package/dist/ui.d.ts +10 -0
  55. package/dist/ui.d.ts.map +1 -0
  56. package/dist/ui.js +9 -0
  57. package/package.json +21 -11
  58. package/src/config.ts +1 -1
  59. package/src/index.ts +1 -1
  60. package/src/site/constants.ts +1 -0
  61. package/src/site/footer.tsx +59 -0
  62. package/src/site/header.test.tsx +89 -0
  63. package/src/site/header.tsx +117 -0
  64. package/src/site/layout.tsx +33 -0
  65. package/src/site/page.tsx +38 -0
  66. package/src/site/pager.tsx +54 -0
  67. package/src/site/sidebar-navigation.test.tsx +92 -0
  68. package/src/site/sidebar-navigation.tsx +267 -0
  69. package/src/site/sidebar.tsx +77 -0
  70. package/src/site/toc.test.tsx +187 -0
  71. package/src/site/toc.tsx +118 -0
  72. package/src/site/tree.ts +36 -0
  73. package/src/site/utils.ts +2 -0
  74. package/src/ui.ts +19 -0
  75. 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
+ );
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export const cn = (...values: (string | false | undefined)[]) =>
2
+ values.filter(Boolean).join(" ");
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";