UI: Add scrollbar to TOC

This commit is contained in:
Fuma Nama
2024-03-07 19:02:39 +08:00
parent 84ec48ea4c
commit 10e099a0b0
7 changed files with 121 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-ui": patch
---
Add scrollbar to TOC
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-core": patch
---
Remove deprecated options from `fumadocs-core/toc`
+37 -16
View File
@@ -1,4 +1,9 @@
import type { AnchorHTMLAttributes, HTMLAttributes, RefObject } from 'react';
import type {
AnchorHTMLAttributes,
HTMLAttributes,
ReactNode,
RefObject,
} from 'react';
import {
createContext,
forwardRef,
@@ -8,7 +13,7 @@ import {
useRef,
} from 'react';
import scrollIntoView from 'scroll-into-view-if-needed';
import type { TableOfContents, TOCItemType } from '@/server/get-toc';
import type { TableOfContents } from '@/server/get-toc';
import { mergeRefs } from '@/utils/merge-refs';
import { useAnchorObserver } from './utils/use-anchor-observer';
@@ -26,26 +31,26 @@ export const useActiveAnchor = (url: string): boolean => {
return activeAnchor === url.split('#')[1];
};
type TOCProviderProps = HTMLAttributes<HTMLDivElement> & {
export interface TOCProviderProps extends HTMLAttributes<HTMLDivElement> {
toc: TableOfContents;
};
}
export interface TOCScrollProvider {
containerRef: RefObject<HTMLElement>;
toc: TableOfContents;
children: ReactNode;
}
export const TOCProvider = forwardRef<HTMLDivElement, TOCProviderProps>(
({ toc, ...props }, ref) => {
const headings = useMemo(() => {
return toc.map((item) => item.url.split('#')[1]);
}, [toc]);
const containerRef = useRef<HTMLDivElement>(null);
const mergedRef = mergeRefs(containerRef, ref);
const activeAnchor = useAnchorObserver(headings);
return (
<div ref={mergedRef} {...props}>
<ActiveAnchorContext.Provider value={{ containerRef, activeAnchor }}>
<TOCScrollProvider toc={toc} containerRef={containerRef}>
{props.children}
</ActiveAnchorContext.Provider>
</TOCScrollProvider>
</div>
);
},
@@ -53,11 +58,27 @@ export const TOCProvider = forwardRef<HTMLDivElement, TOCProviderProps>(
TOCProvider.displayName = 'TOCProvider';
export type TOCItemProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
export function TOCScrollProvider({
toc,
containerRef,
children,
}: TOCScrollProvider): JSX.Element {
const headings = useMemo(() => {
return toc.map((item) => item.url.split('#')[1]);
}, [toc]);
const activeAnchor = useAnchorObserver(headings);
return (
<ActiveAnchorContext.Provider value={{ containerRef, activeAnchor }}>
{children}
</ActiveAnchorContext.Provider>
);
}
export interface TOCItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
/** @deprecated You don't need to pass this anymore */
item?: TOCItemType;
};
}
export const TOCItem = forwardRef<HTMLAnchorElement, TOCItemProps>(
(props, ref) => {
+9 -2
View File
@@ -3,7 +3,11 @@ import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '@/utils/cn';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
ScrollArea,
ScrollBar,
ScrollViewport,
} from '@/components/ui/scroll-area';
import { useCopyButton } from '@/utils/use-copy-button';
import { buttonVariants } from '@/theme/variants';
@@ -73,7 +77,10 @@ export const CodeBlock = forwardRef<HTMLElement, CodeBlockProps>(
/>
)
)}
<ScrollArea ref={areaRef}>{props.children}</ScrollArea>
<ScrollArea ref={areaRef}>
<ScrollViewport>{props.children}</ScrollViewport>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</figure>
);
},
+14 -12
View File
@@ -9,7 +9,7 @@ import Link from 'fumadocs-core/link';
import { cn } from '@/utils/cn';
import { useTreeContext } from '@/contexts/tree';
import { useSidebarCollapse } from '@/contexts/sidebar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { hasActive, isActive } from '@/utils/shared';
import type { LinkItem } from '@/layout';
import {
@@ -101,17 +101,19 @@ export function Sidebar({
>
<SidebarContext.Provider value={context}>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-8 pb-10 pt-4 max-md:px-4 md:pr-3 md:pt-10">
{banner}
{items.length > 0 && (
<div className="lg:hidden">
{items.map((item) => (
<BaseItem key={item.url} item={item} nested />
))}
</div>
)}
<NodeList items={root.children} />
</div>
<ScrollViewport>
<div className="flex flex-col gap-8 pb-10 pt-4 max-md:px-4 md:pr-3 md:pt-10">
{banner}
{items.length > 0 && (
<div className="lg:hidden">
{items.map((item) => (
<BaseItem key={item.url} item={item} nested />
))}
</div>
)}
<NodeList items={root.children} />
</div>
</ScrollViewport>
</ScrollArea>
<div
className={cn(
+33 -27
View File
@@ -1,42 +1,48 @@
import { TextIcon } from 'lucide-react';
import type { TOCItemType } from 'fumadocs-core/server';
import * as Primitive from 'fumadocs-core/toc';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { ScrollArea, ScrollViewport } from './ui/scroll-area';
type PosType = [top: number, height: number];
export function TOCItems({ items }: { items: TOCItemType[] }): JSX.Element {
const { text } = useI18n();
const [pos, setPos] = useState<PosType>();
const containerRef = useRef<HTMLDivElement>(null);
const markerRef = useRef<HTMLDivElement>(null);
const setPos = ([top, height]: PosType): void => {
const element = markerRef.current;
if (!element) return;
element.style.setProperty('top', `${top}px`);
element.style.setProperty('height', `${height}px`);
element.style.setProperty('display', `block`);
};
return (
<Primitive.TOCProvider
toc={items}
className="relative overflow-hidden pt-4 text-sm first:pt-0"
>
<h3 className="mb-4 inline-flex items-center gap-2">
<TextIcon className="size-4" />
{text.toc}
</h3>
<div className="flex flex-col gap-1 border-l-2 text-muted-foreground">
<div
role="none"
className={cn(
'absolute left-0 border-l-2 transition-all',
pos && 'border-primary',
)}
style={{
top: pos?.[0],
height: pos?.[1],
}}
/>
{items.map((item) => (
<TOCItem key={item.url} item={item} setMarker={setPos} />
))}
</div>
</Primitive.TOCProvider>
<ScrollArea className="pt-4 text-sm first:pt-0">
<ScrollViewport className="relative" ref={containerRef}>
<Primitive.TOCScrollProvider containerRef={containerRef} toc={items}>
<div
role="none"
ref={markerRef}
className="absolute left-0 hidden border-l-2 border-primary transition-all"
/>
<h3 className="mb-4 inline-flex items-center gap-2">
<TextIcon className="size-4" />
{text.toc}
</h3>
<div className="flex flex-col gap-1 border-l-2 text-muted-foreground">
{items.map((item) => (
<TOCItem key={item.url} item={item} setMarker={setPos} />
))}
</div>
</Primitive.TOCScrollProvider>
</ScrollViewport>
</ScrollArea>
);
}
+18 -5
View File
@@ -11,16 +11,29 @@ const ScrollArea = React.forwardRef<
className={cn('overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
{children}
<ScrollAreaPrimitive.Corner />
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollViewport = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Viewport
ref={ref}
className={cn('size-full rounded-[inherit]', className)}
{...props}
>
{children}
</ScrollAreaPrimitive.Viewport>
));
ScrollViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Scrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
@@ -41,4 +54,4 @@ const ScrollBar = React.forwardRef<
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea };
export { ScrollArea, ScrollBar, ScrollViewport };