mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
UI: Add scrollbar to TOC
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"fumadocs-ui": patch
|
||||
---
|
||||
|
||||
Add scrollbar to TOC
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"fumadocs-core": patch
|
||||
---
|
||||
|
||||
Remove deprecated options from `fumadocs-core/toc`
|
||||
+37
-16
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user