mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
134 lines
4.1 KiB
TypeScript
134 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import type {FC} from "react";
|
|
import type {Heading} from "@/libs/docs/utils";
|
|
|
|
import {useRef, useEffect, useState} from "react";
|
|
import {cn} from "@heroui/theme";
|
|
import {Divider, Spacer} from "@heroui/react";
|
|
import {ChevronCircleTopLinearIcon} from "@heroui/shared-icons";
|
|
import scrollIntoView from "scroll-into-view-if-needed";
|
|
|
|
import {HeroUIProCallout} from "./heroui-pro-callout";
|
|
|
|
import {useScrollSpy} from "@/hooks/use-scroll-spy";
|
|
import {useScrollPosition} from "@/hooks/use-scroll-position";
|
|
import emitter from "@/libs/emitter";
|
|
|
|
export interface DocsTocProps {
|
|
headings: Heading[];
|
|
}
|
|
|
|
const paddingLeftByLevel: Record<number, string> = {
|
|
1: "pl-0",
|
|
2: "pl-0",
|
|
3: "pl-3",
|
|
4: "pl-6",
|
|
};
|
|
|
|
export const DocsToc: FC<DocsTocProps> = ({headings}) => {
|
|
const [isProBannerVisible, setIsProBannerVisible] = useState(true);
|
|
|
|
const tocRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollPosition = useScrollPosition(tocRef);
|
|
|
|
const activeId = useScrollSpy(
|
|
headings.map(({id}) => `[id="${id}"]`),
|
|
{
|
|
rootMargin: "0% 0% -80% 0%",
|
|
},
|
|
);
|
|
|
|
const activeIndex = headings.findIndex(({id}) => id == activeId);
|
|
const firstId = headings[0].id;
|
|
|
|
useEffect(() => {
|
|
if (!activeId || activeIndex < 2) return;
|
|
const anchor = tocRef.current?.querySelector(`li > a[href="#${activeId}"]`);
|
|
|
|
if (anchor) {
|
|
scrollIntoView(anchor, {
|
|
behavior: "smooth",
|
|
block: "center",
|
|
inline: "center",
|
|
scrollMode: "always",
|
|
boundary: tocRef.current,
|
|
});
|
|
}
|
|
}, [activeId, activeIndex]);
|
|
|
|
useEffect(() => {
|
|
emitter.on("proBannerVisibilityChange", (value) => {
|
|
setIsProBannerVisible(value === "visible");
|
|
});
|
|
|
|
return () => {
|
|
emitter.off("proBannerVisibilityChange");
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className={cn("fixed", isProBannerVisible ? "top-32" : "top-20")}>
|
|
<div
|
|
ref={tocRef}
|
|
className="w-full max-w-[12rem] max-h-[calc(100vh-500px)] flex flex-col gap-4 text-left pb-16 scrollbar-hide overflow-y-scroll"
|
|
style={{
|
|
WebkitMaskImage: `linear-gradient(to top, transparent 0%, #000 100px, #000 ${
|
|
scrollPosition > 30 ? "90%" : "100%"
|
|
}, transparent 100%)`,
|
|
}}
|
|
>
|
|
<p className="text-sm font-medium">On this page</p>
|
|
<ul className="scrollbar-hide flex flex-col gap-2">
|
|
{headings.map(
|
|
(heading, i) =>
|
|
heading.level > 1 && (
|
|
<li
|
|
key={i}
|
|
className={cn(
|
|
"relative",
|
|
"transition-colors",
|
|
"font-normal",
|
|
"flex items-center text-tiny font-normal text-default-500",
|
|
"data-[active=true]:text-foreground",
|
|
"dark:data-[active=true]:text-foreground",
|
|
"before:content-['']",
|
|
"before:opacity-0",
|
|
"data-[active=true]:before:opacity-100",
|
|
"before:transition-opacity",
|
|
"before:-ml-3",
|
|
"before:absolute",
|
|
"before:bg-default-400",
|
|
"before:w-1",
|
|
"before:h-1",
|
|
"before:rounded-full",
|
|
paddingLeftByLevel[heading.level],
|
|
)}
|
|
data-active={activeId == heading.id}
|
|
>
|
|
<a href={`#${heading.id}`}>{heading.text}</a>
|
|
</li>
|
|
),
|
|
)}
|
|
<li
|
|
className="mt-2 opacity-0 data-[visible=true]:opacity-100 transition-opacity"
|
|
data-visible={activeIndex >= 2}
|
|
>
|
|
<Divider />
|
|
<Spacer y={2} />
|
|
<a
|
|
className="flex gap-2 items-center text-tiny text-default-500 dark:text-foreground/30 hover:text-foreground/80 pl-4 transition-opacity"
|
|
href={`#${firstId}`}
|
|
>
|
|
Back to top
|
|
<ChevronCircleTopLinearIcon />
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<HeroUIProCallout />
|
|
</div>
|
|
);
|
|
};
|