shapes-ui 0.5.0 → 0.6.0
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/.github/workflows/pr-preview.yml +9 -2
- package/CHANGELOG.md +11 -0
- package/README.md +13 -0
- package/content/components/accordion.mdx +13 -0
- package/content/components/alert-dialog.mdx +34 -0
- package/content/components/autocomplete.mdx +62 -0
- package/content/components/avatar.mdx +11 -0
- package/content/components/button.mdx +8 -0
- package/content/components/checkbox.mdx +11 -0
- package/content/components/collapsible.mdx +11 -0
- package/content/components/combobox.mdx +33 -0
- package/content/components/context-menu.mdx +29 -0
- package/content/components/dialog.mdx +33 -0
- package/content/components/drawer.mdx +38 -0
- package/content/components/field.mdx +21 -0
- package/content/components/fieldset.mdx +10 -0
- package/content/components/form.mdx +8 -0
- package/content/components/input.mdx +4 -0
- package/content/components/menu.mdx +27 -0
- package/content/components/menubar.mdx +31 -0
- package/content/components/meter.mdx +14 -0
- package/content/components/navigation-menu.mdx +28 -0
- package/content/components/number-field.mdx +25 -0
- package/content/components/popover.mdx +22 -0
- package/content/components/preview-card.mdx +14 -2
- package/content/components/progress.mdx +15 -1
- package/content/components/radio.mdx +11 -0
- package/content/components/scroll-area.mdx +23 -0
- package/content/components/select.mdx +27 -0
- package/content/components/separator.mdx +29 -0
- package/content/components/slider.mdx +4 -0
- package/content/components/switch.mdx +4 -0
- package/content/components/tabs.mdx +15 -0
- package/content/components/toast.mdx +10 -0
- package/content/components/toggle-group.mdx +37 -0
- package/content/components/toggle.mdx +12 -0
- package/content/components/toolbar.mdx +22 -0
- package/content/components/tooltip.mdx +13 -0
- package/content/docs/installation.mdx +30 -0
- package/content-collections.ts +65 -1
- package/dist/cli.js +947 -101
- package/examples/__index.tsx +136 -68
- package/examples/autocomplete-align.tsx +39 -0
- package/examples/autocomplete-controlled.tsx +44 -0
- package/examples/autocomplete-groups.tsx +65 -0
- package/examples/autocomplete-no-clear.tsx +40 -0
- package/examples/avatar-demo.tsx +3 -3
- package/examples/input-group-with-button.tsx +1 -1
- package/examples/separator-demo.tsx +13 -0
- package/examples/separator-horizontal.tsx +18 -0
- package/package.json +19 -18
- package/public/base-ui.svg +1 -0
- package/src/assets/base-ui.svg +1 -0
- package/src/commands/add.ts +79 -38
- package/src/commands/cli.ts +50 -3
- package/src/commands/create.ts +262 -0
- package/src/commands/init.ts +45 -12
- package/src/commands/palette.ts +55 -0
- package/src/components/docs/layout/footer.tsx +2 -2
- package/src/components/docs/layout/header.tsx +7 -9
- package/src/components/docs/layout/mobile-menu.tsx +0 -1
- package/src/components/docs/layout/nav-list.tsx +2 -2
- package/src/components/docs/layout/page-header.tsx +52 -7
- package/src/components/docs/layout/split-layout.tsx +9 -10
- package/src/components/docs/layout/table-of-content.tsx +145 -0
- package/src/components/docs/markdown/components.tsx +142 -29
- package/src/components/docs/markdown/copy-button.tsx +41 -0
- package/src/components/docs/markdown/installation-block.tsx +5 -24
- package/src/components/docs/markdown/render-preview.tsx +1 -1
- package/src/components/ui/button-group.tsx +1 -1
- package/src/components/ui/scroll-area.tsx +11 -2
- package/src/lib/docs-headings.ts +72 -0
- package/src/routeTree.gen.ts +60 -3
- package/src/routes/__root.tsx +2 -2
- package/src/routes/components.$slug.tsx +20 -4
- package/src/routes/docs.$slug.tsx +78 -0
- package/src/routes/docs.tsx +15 -0
- package/src/styles/styles.css +1 -1
- package/src/utils/cli-utils.ts +8 -8
- package/src/utils/dependency-installer.ts +30 -0
- package/src/utils/package-manager.ts +4 -4
- package/src/utils/palette.ts +666 -0
- package/src/utils/schema.ts +6 -0
package/src/commands/init.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { confirm, intro, note, outro, select, text
|
|
4
|
-
import { execa } from "execa";
|
|
3
|
+
import { confirm, intro, note, outro, select, text } from "@clack/prompts";
|
|
5
4
|
import figlet from "figlet";
|
|
6
5
|
import fs from "fs-extra";
|
|
7
6
|
import gradient from "gradient-string";
|
|
@@ -15,7 +14,12 @@ import {
|
|
|
15
14
|
isViteProject,
|
|
16
15
|
readPackageJson,
|
|
17
16
|
} from "@/utils/cli-utils";
|
|
18
|
-
import {
|
|
17
|
+
import { installDependencies } from "@/utils/dependency-installer";
|
|
18
|
+
import {
|
|
19
|
+
type ContrastMode,
|
|
20
|
+
getDefaultBrandPaletteOptions,
|
|
21
|
+
writePaletteTokens,
|
|
22
|
+
} from "@/utils/palette";
|
|
19
23
|
import { type Config } from "@/utils/schema";
|
|
20
24
|
|
|
21
25
|
const BASE_DEPS = [
|
|
@@ -28,15 +32,10 @@ const BASE_DEPS = [
|
|
|
28
32
|
];
|
|
29
33
|
|
|
30
34
|
async function installDeps(deps: string[], dev = false) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const [command, ...args] = await getInstallCommand(deps, dev);
|
|
37
|
-
await execa(command, args);
|
|
38
|
-
|
|
39
|
-
spin.stop(pc.green("Dependencies installed"));
|
|
35
|
+
await installDependencies(deps, {
|
|
36
|
+
dev,
|
|
37
|
+
successMessage: pc.green("Dependencies installed"),
|
|
38
|
+
});
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
async function ensureTailwindStyles(cssPath: string) {
|
|
@@ -81,7 +80,30 @@ export async function initCommand() {
|
|
|
81
80
|
options: [
|
|
82
81
|
{ label: "Default", value: "default" },
|
|
83
82
|
{ label: "Brutalist", value: "brutalist" },
|
|
83
|
+
{ label: "Minimal", value: "minimal" },
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const palette = exitIfCancelled(
|
|
89
|
+
await select({
|
|
90
|
+
message: "Pick a brand palette",
|
|
91
|
+
options: getDefaultBrandPaletteOptions().map((name) => ({
|
|
92
|
+
label: name[0].toUpperCase() + name.slice(1),
|
|
93
|
+
value: name,
|
|
94
|
+
})),
|
|
95
|
+
initialValue: "blue",
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const contrastMode = exitIfCancelled(
|
|
100
|
+
await select({
|
|
101
|
+
message: "Choose foreground contrast strategy",
|
|
102
|
+
options: [
|
|
103
|
+
{ label: "Deterministic shades", value: "deterministic" },
|
|
104
|
+
{ label: "Dynamic contrast", value: "dynamic" },
|
|
84
105
|
],
|
|
106
|
+
initialValue: "deterministic",
|
|
85
107
|
}),
|
|
86
108
|
);
|
|
87
109
|
|
|
@@ -121,9 +143,20 @@ export async function initCommand() {
|
|
|
121
143
|
await installDeps(missingBaseDeps, false);
|
|
122
144
|
await ensureTailwindStyles(cssPath);
|
|
123
145
|
|
|
146
|
+
const paletteResult = await writePaletteTokens({
|
|
147
|
+
cssPath,
|
|
148
|
+
paletteName: palette as string,
|
|
149
|
+
neutralPalette: "zinc",
|
|
150
|
+
contrastMode: contrastMode as ContrastMode,
|
|
151
|
+
});
|
|
152
|
+
|
|
124
153
|
const config: Config = {
|
|
125
154
|
$schema: "https://shapes-ui.com/schema.json",
|
|
126
155
|
style: style as Config["style"],
|
|
156
|
+
palette: {
|
|
157
|
+
name: paletteResult.paletteName,
|
|
158
|
+
contrastMode: paletteResult.contrastMode,
|
|
159
|
+
},
|
|
127
160
|
tailwind: { css: cssPath as string, baseColor: "zinc" },
|
|
128
161
|
paths: { ui: uiPath as string, lib: "./src/lib" },
|
|
129
162
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { intro, note, outro } from "@clack/prompts";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
|
|
7
|
+
import { getConfig } from "@/utils/cli-utils";
|
|
8
|
+
import { normalizeContrastMode, writePaletteTokens } from "@/utils/palette";
|
|
9
|
+
|
|
10
|
+
type PaletteSetOptions = {
|
|
11
|
+
cssPath?: string;
|
|
12
|
+
contrastMode?: string;
|
|
13
|
+
refresh?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function paletteSetCommand(name: string, options: PaletteSetOptions = {}) {
|
|
17
|
+
intro(pc.bgCyan(pc.black(" Update Shapes palette ")));
|
|
18
|
+
|
|
19
|
+
const config = await getConfig();
|
|
20
|
+
if (!config) {
|
|
21
|
+
throw new Error("Could not find shapes.json. Run `shapes-ui init` first.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const contrastMode = normalizeContrastMode(options.contrastMode ?? config.palette?.contrastMode);
|
|
25
|
+
const cssPath = options.cssPath ?? config.tailwind.css;
|
|
26
|
+
const neutralPalette = config.tailwind.baseColor;
|
|
27
|
+
|
|
28
|
+
const result = await writePaletteTokens({
|
|
29
|
+
cssPath,
|
|
30
|
+
paletteName: name,
|
|
31
|
+
neutralPalette,
|
|
32
|
+
contrastMode,
|
|
33
|
+
refresh: options.refresh,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const updatedConfig = {
|
|
37
|
+
...config,
|
|
38
|
+
palette: {
|
|
39
|
+
name: result.paletteName,
|
|
40
|
+
contrastMode: result.contrastMode,
|
|
41
|
+
},
|
|
42
|
+
tailwind: {
|
|
43
|
+
...config.tailwind,
|
|
44
|
+
css: cssPath,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
await fs.writeJSON(path.join(process.cwd(), "shapes.json"), updatedConfig, { spaces: 2 });
|
|
49
|
+
|
|
50
|
+
note(
|
|
51
|
+
`palette=${result.paletteName} neutral=${result.neutralPalette} contrast=${result.contrastMode} source=${result.source}`,
|
|
52
|
+
"Palette updated",
|
|
53
|
+
);
|
|
54
|
+
outro(pc.green("Shapes UI palette updated."));
|
|
55
|
+
}
|
|
@@ -2,8 +2,8 @@ import { Badge } from "@/components/ui/badge";
|
|
|
2
2
|
|
|
3
3
|
export function Footer() {
|
|
4
4
|
return (
|
|
5
|
-
<footer className=" h-12
|
|
6
|
-
<div className=" container mx-auto flex h-full items-center
|
|
5
|
+
<footer className=" z-10 h-12">
|
|
6
|
+
<div className=" container mx-auto flex h-full items-center px-4">
|
|
7
7
|
<Badge variant={"info"}>Alpha</Badge>
|
|
8
8
|
</div>
|
|
9
9
|
</footer>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { SunMoonIcon } from "lucide-react";
|
|
4
3
|
|
|
5
4
|
import { DocsButton } from "@/components/docs/docs-button";
|
|
6
5
|
import { useTheme } from "@/components/docs/theme-provider";
|
|
@@ -10,16 +9,16 @@ import { MobileMenu } from "./mobile-menu";
|
|
|
10
9
|
export function Header() {
|
|
11
10
|
const { toggleTheme, theme } = useTheme();
|
|
12
11
|
|
|
13
|
-
|
|
14
12
|
return (
|
|
15
|
-
<header className=" sticky top-0 z-10 h-12
|
|
16
|
-
<div className=" container mx-auto flex h-full items-center justify-between
|
|
17
|
-
<Link to="/">
|
|
13
|
+
<header className=" sticky top-0 z-10 h-12">
|
|
14
|
+
<div className=" container mx-auto flex h-full items-center justify-between px-4">
|
|
15
|
+
<Link to="/" className="flex items-center gap-2 font-medium font-heading tracking-wide">
|
|
18
16
|
<img
|
|
19
17
|
src={theme === "dark" ? "/shps_white.svg" : "/shps_black.svg"}
|
|
20
18
|
alt="Shapes UI Logo"
|
|
21
|
-
className=" size-
|
|
19
|
+
className=" size-5"
|
|
22
20
|
/>
|
|
21
|
+
shapes ui
|
|
23
22
|
</Link>
|
|
24
23
|
|
|
25
24
|
<div className=" mx-auto hidden gap-4 lg:flex">
|
|
@@ -31,9 +30,8 @@ export function Header() {
|
|
|
31
30
|
<DocsButton onClick={toggleTheme} variant={"ghost"} className={"hidden lg:inline-flex"}>
|
|
32
31
|
<SunMoonIcon className=" size-4" />
|
|
33
32
|
</DocsButton>
|
|
34
|
-
|
|
33
|
+
<MobileMenu />
|
|
35
34
|
</div>
|
|
36
|
-
|
|
37
35
|
</header>
|
|
38
36
|
);
|
|
39
37
|
}
|
|
@@ -13,7 +13,6 @@ export function MobileMenu() {
|
|
|
13
13
|
return <Drawer open={open} onOpenChange={setOpen} >
|
|
14
14
|
<DrawerTrigger className={'flex md:hidden'} render={<Button size={'xs'} variant={'secondary'} >
|
|
15
15
|
<MenuIcon />
|
|
16
|
-
Navigation
|
|
17
16
|
</Button>} />
|
|
18
17
|
<DrawerPopup position="bottom">
|
|
19
18
|
<nav className="mt-4 flex flex-col gap-2">
|
|
@@ -17,9 +17,9 @@ function NavSection({ title, children }: { title: string; children: React.ReactN
|
|
|
17
17
|
|
|
18
18
|
export function NavSidebar({ className, ...props }: ComponentProps<"nav">) {
|
|
19
19
|
return (
|
|
20
|
-
<nav className={cn("hidden h-full min-h-0 w-64 flex-col p-4 lg:flex", className)} {...props}>
|
|
20
|
+
<nav className={cn("hidden h-full min-h-0 w-64 flex-col p-4 bg-card lg:flex", className)} {...props}>
|
|
21
21
|
<NavSection title="Components">
|
|
22
|
-
<ScrollArea className="h-
|
|
22
|
+
<ScrollArea className="h-[98%]">
|
|
23
23
|
<ul>
|
|
24
24
|
{[...allComponents]
|
|
25
25
|
.sort((a, b) => a.title.localeCompare(b.title))
|
|
@@ -1,33 +1,78 @@
|
|
|
1
|
+
import { IconMarkdown } from "@tabler/icons-react";
|
|
2
|
+
import { ChevronDownIcon } from "lucide-react";
|
|
1
3
|
import { ComponentProps } from "react";
|
|
2
4
|
|
|
5
|
+
import BaseUI from "@/assets/base-ui.svg?url";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "@/components/ui/button-group";
|
|
8
|
+
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
|
|
3
9
|
import { cn } from "@/lib/utils";
|
|
4
10
|
|
|
5
|
-
import CircuitBoard from "./circuit-board";
|
|
6
|
-
|
|
7
11
|
export function PageHeader({
|
|
8
12
|
title,
|
|
9
13
|
subtitle,
|
|
10
14
|
children,
|
|
15
|
+
baseUILink,
|
|
16
|
+
markdownLink,
|
|
11
17
|
className,
|
|
12
18
|
...props
|
|
13
19
|
}: ComponentProps<"div"> & {
|
|
14
20
|
title: string;
|
|
15
21
|
subtitle?: string;
|
|
22
|
+
baseUILink?: string;
|
|
23
|
+
markdownLink?: string;
|
|
16
24
|
}) {
|
|
25
|
+
const handleOpenMarkdown = () => {
|
|
26
|
+
if (!markdownLink) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
window.open(markdownLink, "_blank", "noopener,noreferrer");
|
|
31
|
+
};
|
|
32
|
+
|
|
17
33
|
return (
|
|
18
34
|
<div
|
|
19
35
|
className={cn(
|
|
20
|
-
"flex
|
|
36
|
+
"flex items-start justify-between overflow-hidden bg-background max-w-7xl mx-auto p-4 lg:px-0 ",
|
|
21
37
|
className,
|
|
22
38
|
)}
|
|
23
39
|
{...props}
|
|
24
40
|
>
|
|
25
|
-
<div className=" flex flex-col
|
|
26
|
-
<h1 className="font-heading text-
|
|
27
|
-
{subtitle && <p className="text-xs text-muted-foreground md:text-sm">{subtitle}</p>}
|
|
41
|
+
<div className=" flex flex-col">
|
|
42
|
+
<h1 className="font-heading text-2xl font-bold xl:text-3xl">{title}</h1>
|
|
43
|
+
{subtitle && <p className="text-xs w-2/3 text-muted-foreground md:text-sm">{subtitle}</p>}
|
|
28
44
|
{children}
|
|
29
45
|
</div>
|
|
30
|
-
|
|
46
|
+
|
|
47
|
+
<div className="flex flex-col">
|
|
48
|
+
<ButtonGroup>
|
|
49
|
+
<ButtonGroupText>
|
|
50
|
+
View
|
|
51
|
+
</ButtonGroupText>
|
|
52
|
+
<ButtonGroupSeparator />
|
|
53
|
+
<Menu>
|
|
54
|
+
<MenuTrigger
|
|
55
|
+
render={
|
|
56
|
+
<Button variant={"secondary"} size={"icon"}>
|
|
57
|
+
<ChevronDownIcon />
|
|
58
|
+
</Button>
|
|
59
|
+
}
|
|
60
|
+
/>
|
|
61
|
+
<MenuPopup align="end">
|
|
62
|
+
<MenuItem onClick={handleOpenMarkdown} disabled={!markdownLink}>
|
|
63
|
+
<IconMarkdown />
|
|
64
|
+
Markdown
|
|
65
|
+
</MenuItem>
|
|
66
|
+
{baseUILink && (
|
|
67
|
+
<MenuItem>
|
|
68
|
+
<img className="size-4" alt="Base UI" src={BaseUI} />
|
|
69
|
+
Base UI
|
|
70
|
+
</MenuItem>
|
|
71
|
+
)}
|
|
72
|
+
</MenuPopup>
|
|
73
|
+
</Menu>
|
|
74
|
+
</ButtonGroup>
|
|
75
|
+
</div>
|
|
31
76
|
</div>
|
|
32
77
|
);
|
|
33
78
|
}
|
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
import { ComponentProps } from "react";
|
|
1
|
+
import { ComponentProps, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
5
|
|
|
6
6
|
import { NavSidebar } from "./nav-list";
|
|
7
|
+
import { TableOfContent } from "./table-of-content";
|
|
7
8
|
|
|
8
|
-
export function SplitLayout({
|
|
9
|
-
|
|
10
|
-
className,
|
|
9
|
+
export function SplitLayout({ children, className, ...props }: ComponentProps<"div">) {
|
|
10
|
+
const [contentViewport, setContentViewport] = useState<HTMLDivElement | null>(null);
|
|
11
11
|
|
|
12
|
-
...props
|
|
13
|
-
}: ComponentProps<"div">) {
|
|
14
12
|
return (
|
|
15
|
-
<div className={cn("flex h-full min-h-0 w-full", className)} {...props}>
|
|
13
|
+
<div className={cn("flex h-full min-h-0 w-full border", className)} {...props}>
|
|
16
14
|
<NavSidebar />
|
|
17
|
-
<main className="min-w-0 flex-1">
|
|
18
|
-
<ScrollArea className="h-full">
|
|
19
|
-
<div className="min-h-full
|
|
15
|
+
<main className="min-w-0 flex-1 bg-card ">
|
|
16
|
+
<ScrollArea viewportRef={setContentViewport} className="h-full">
|
|
17
|
+
<div className="min-h-full">{children}</div>
|
|
20
18
|
</ScrollArea>
|
|
21
19
|
</main>
|
|
20
|
+
<TableOfContent contentViewport={contentViewport} />
|
|
22
21
|
</div>
|
|
23
22
|
);
|
|
24
23
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { allComponents, allDocs } from "content-collections";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
6
|
+
import type { TocHeading } from "@/lib/docs-headings";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
function getRouteContent(pathname: string): TocHeading[] {
|
|
10
|
+
const [section, slug] = pathname.split("/").filter(Boolean);
|
|
11
|
+
|
|
12
|
+
if (!slug) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (section === "components") {
|
|
17
|
+
const current = allComponents.find((component) => component.slug === slug) as
|
|
18
|
+
| { toc?: TocHeading[] }
|
|
19
|
+
| undefined;
|
|
20
|
+
|
|
21
|
+
return current?.toc ?? [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (section === "docs") {
|
|
25
|
+
const current = allDocs.find((doc) => doc.slug === slug) as { toc?: TocHeading[] } | undefined;
|
|
26
|
+
|
|
27
|
+
return current?.toc ?? [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TableOfContentProps = {
|
|
34
|
+
contentViewport: HTMLDivElement | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const TableOfContent = ({ contentViewport }: TableOfContentProps) => {
|
|
38
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
39
|
+
const toc = useMemo(() => getRouteContent(pathname), [pathname]);
|
|
40
|
+
const [activeId, setActiveId] = useState<string>(toc[0]?.id ?? "");
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!toc.length) {
|
|
44
|
+
setActiveId("");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let frameId = 0;
|
|
49
|
+
let warmupInterval: number | undefined;
|
|
50
|
+
|
|
51
|
+
const computeActiveHeading = () => {
|
|
52
|
+
const topOffset = contentViewport ? contentViewport.getBoundingClientRect().top + 96 : 120;
|
|
53
|
+
|
|
54
|
+
const renderedHeadings = toc
|
|
55
|
+
.map((heading) => ({
|
|
56
|
+
id: heading.id,
|
|
57
|
+
element: document.getElementById(heading.id),
|
|
58
|
+
}))
|
|
59
|
+
.filter((item) => item.element);
|
|
60
|
+
|
|
61
|
+
if (!renderedHeadings.length) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let nextActive = renderedHeadings[0]?.id ?? "";
|
|
66
|
+
|
|
67
|
+
for (const item of renderedHeadings) {
|
|
68
|
+
if ((item.element?.getBoundingClientRect().top ?? Number.POSITIVE_INFINITY) <= topOffset) {
|
|
69
|
+
nextActive = item.id;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setActiveId((prev) => (prev === nextActive ? prev : nextActive));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const scheduleActiveUpdate = () => {
|
|
80
|
+
if (frameId) {
|
|
81
|
+
cancelAnimationFrame(frameId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
frameId = requestAnimationFrame(() => {
|
|
85
|
+
computeActiveHeading();
|
|
86
|
+
frameId = 0;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
contentViewport?.addEventListener("scroll", scheduleActiveUpdate, { passive: true });
|
|
91
|
+
window.addEventListener("resize", scheduleActiveUpdate);
|
|
92
|
+
|
|
93
|
+
scheduleActiveUpdate();
|
|
94
|
+
warmupInterval = window.setInterval(scheduleActiveUpdate, 80);
|
|
95
|
+
window.setTimeout(() => {
|
|
96
|
+
if (warmupInterval) {
|
|
97
|
+
window.clearInterval(warmupInterval);
|
|
98
|
+
}
|
|
99
|
+
}, 1500);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (frameId) {
|
|
103
|
+
cancelAnimationFrame(frameId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (warmupInterval) {
|
|
107
|
+
window.clearInterval(warmupInterval);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
contentViewport?.removeEventListener("scroll", scheduleActiveUpdate);
|
|
111
|
+
window.removeEventListener("resize", scheduleActiveUpdate);
|
|
112
|
+
};
|
|
113
|
+
}, [contentViewport, pathname, toc]);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<aside className="hidden h-full min-h-0 w-64 flex-col p-4 xl:flex">
|
|
117
|
+
<h4 className="pl-2 font-heading text-xs font-medium text-muted-foreground">On this page</h4>
|
|
118
|
+
<ScrollArea className="mt-2 h-[98%]">
|
|
119
|
+
<ul className="space-y-1">
|
|
120
|
+
{toc.map((heading) => {
|
|
121
|
+
const isActive = activeId === heading.id;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<li key={heading.id}>
|
|
125
|
+
<Link
|
|
126
|
+
to="."
|
|
127
|
+
hash={heading.id}
|
|
128
|
+
style={{ paddingLeft: `${(heading.level - 1) * 10}px` }}
|
|
129
|
+
className={cn(
|
|
130
|
+
"block truncate rounded px-2 py-1.5 text-xs transition-colors",
|
|
131
|
+
isActive
|
|
132
|
+
? "bg-muted text-foreground"
|
|
133
|
+
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{heading.title}
|
|
137
|
+
</Link>
|
|
138
|
+
</li>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</ul>
|
|
142
|
+
</ScrollArea>
|
|
143
|
+
</aside>
|
|
144
|
+
);
|
|
145
|
+
};
|