UI: refactor TOC slot

This commit is contained in:
Fuma Nama
2026-03-20 10:23:30 +08:00
parent 11b869167e
commit 75b0b949a9
17 changed files with 1093 additions and 1064 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
---
Refactor TOC slot
+1 -4
View File
@@ -61,7 +61,6 @@
"./layouts/docs/page/slots/container": "./dist/layouts/docs/page/slots/container.js",
"./layouts/docs/page/slots/footer": "./dist/layouts/docs/page/slots/footer.js",
"./layouts/docs/page/slots/toc": "./dist/layouts/docs/page/slots/toc.js",
"./layouts/docs/page/slots/toc-popover": "./dist/layouts/docs/page/slots/toc-popover.js",
"./layouts/docs/slots/container": "./dist/layouts/docs/slots/container.js",
"./layouts/docs/slots/header": "./dist/layouts/docs/slots/header.js",
"./layouts/docs/slots/sidebar": "./dist/layouts/docs/slots/sidebar.js",
@@ -85,7 +84,6 @@
"./layouts/notebook/page/slots/container": "./dist/layouts/notebook/page/slots/container.js",
"./layouts/notebook/page/slots/footer": "./dist/layouts/notebook/page/slots/footer.js",
"./layouts/notebook/page/slots/toc": "./dist/layouts/notebook/page/slots/toc.js",
"./layouts/notebook/page/slots/toc-popover": "./dist/layouts/notebook/page/slots/toc-popover.js",
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
@@ -111,8 +109,7 @@
"./utils/use-is-scroll-top": "./dist/utils/use-is-scroll-top.js",
"./package.json": "./package.json",
"./style.css": "./dist/style.css",
"./css/*": "./css/*",
"./image-zoom.css": "./dist/image-zoom.css"
"./css/*": "./css/*"
},
"publishConfig": {
"access": "public"
@@ -19,7 +19,9 @@ export function useTOCItems(): Primitive.TOCItemType[] {
return use(TOCContext);
}
export type TOCProviderProps = ComponentProps<typeof Primitive.AnchorProvider>;
export type TOCProviderProps = Primitive.AnchorProviderProps;
export const { useActiveAnchor, useActiveAnchors } = Primitive;
export function TOCProvider({ toc, children, ...props }: TOCProviderProps) {
return (
@@ -56,7 +58,7 @@ interface RefProps {
export function TocThumb({ containerRef, ...props }: ComponentProps<'div'> & RefProps) {
const thumbRef = useRef<HTMLDivElement>(null);
const active = Primitive.useActiveAnchors();
const active = useActiveAnchors();
function update(info: TocThumbType): void {
const element = thumbRef.current;
if (!element) return;
@@ -11,11 +11,16 @@ import {
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
import {
TOC,
TOCPopover,
TOCProvider,
type TOCProviderProps,
type TOCPopoverProps,
type TOCProps,
} from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
@@ -69,10 +74,12 @@ interface TableOfContentPopoverOptions extends TOCPopoverProps {
}
export interface DocsPageSlots {
toc?: FC<TOCProps>;
toc?: {
provider: FC<TOCProviderProps>;
main?: FC<TOCProps>;
popover?: FC<TOCPopoverProps>;
};
container?: FC<ComponentProps<'article'>>;
tocPopover?: FC<TOCPopoverProps>;
tocProvider?: FC<TOCProviderProps>;
footer?: FC<FooterProps>;
breadcrumb?: FC<BreadcrumbProps>;
}
@@ -97,7 +104,7 @@ export function useDocsPage() {
}
export function DocsPage({
tableOfContent: { enabled: tocEnabled, single = false, ...tocProps } = {},
tableOfContent: { enabled: tocEnabled, single, ...tocProps } = {},
tableOfContentPopover: { enabled: tocPopoverEnabled, ...tocPopoverProps } = {},
footer = {},
breadcrumb = {},
@@ -114,15 +121,17 @@ export function DocsPage({
breadcrumb:
breadcrumb.enabled !== false ? (defaultSlots.breadcrumb ?? InlineBreadcrumb) : undefined,
footer: footer.enabled !== false ? (defaultSlots.footer ?? InlineFooter) : undefined,
toc: tocEnabled ? (defaultSlots.toc ?? InlineTOC) : undefined,
tocPopover: tocPopoverEnabled ? (defaultSlots.tocPopover ?? InlineTOCPopover) : undefined,
tocProvider: defaultSlots.tocProvider ?? TOCProvider,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: tocEnabled ? InlineTOC : undefined,
popover: tocPopoverEnabled ? InlineTOCPopover : undefined,
},
container: defaultSlots.container ?? Container,
};
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.toc?.popover && <slots.toc.popover />}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
@@ -130,15 +139,15 @@ export function DocsPage({
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
{slots.toc?.main && <slots.toc.main />}
</>
);
if (slots.tocProvider)
if (slots.toc)
content = (
<slots.tocProvider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
<slots.toc.provider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
{content}
</slots.tocProvider>
</slots.toc.provider>
);
return (
@@ -1,243 +0,0 @@
'use client';
import { TOCScrollArea, useTOCItems } from '@/components/toc';
import { useI18n } from '@/contexts/i18n';
import { useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useActiveAnchor } from 'fumadocs-core/toc';
import { ChevronDown } from 'lucide-react';
import {
ComponentProps,
useRef,
useState,
useEffectEvent,
useEffect,
useMemo,
use,
createContext,
ReactNode,
} from 'react';
import { useDocsLayout } from '@/layouts/docs/client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useDocsLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -1,11 +1,30 @@
'use client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCScrollArea } from '@/components/toc';
import { I18nLabel } from '@/contexts/i18n';
import * as Base from '@/components/toc';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { Text } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { ChevronDown, Text } from 'lucide-react';
import {
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { useTreePath } from '@/contexts/tree';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useDocsLayout } from '../..';
export type TOCProviderProps = Base.TOCProviderProps;
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
@@ -43,10 +62,231 @@ export function TOC({ container, header, footer, style }: TOCProps) {
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
</Base.TOCScrollArea>
{footer}
</div>
);
}
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useDocsLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useTOCItems();
const active = Base.useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -11,19 +11,26 @@ import {
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
import {
TOC,
TOCPopover,
TOCProvider,
type TOCProviderProps,
type TOCPopoverProps,
type TOCProps,
} from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
export interface DocsPageSlots {
toc?: FC<TOCProps>;
toc?: {
provider: FC<TOCProviderProps>;
main?: FC<TOCProps>;
popover?: FC<TOCPopoverProps>;
};
container?: FC<ComponentProps<'article'>>;
tocPopover?: FC<TOCPopoverProps>;
tocProvider?: FC<TOCProviderProps>;
footer?: FC<FooterProps>;
breadcrumb?: FC<BreadcrumbProps>;
}
@@ -96,7 +103,7 @@ export function useDocsPage() {
}
export function DocsPage({
tableOfContent: { enabled: tocEnabled, single = false, ...tocProps } = {},
tableOfContent: { enabled: tocEnabled, single, ...tocProps } = {},
tableOfContentPopover: { enabled: tocPopoverEnabled, ...tocPopoverProps } = {},
footer = {},
breadcrumb = {},
@@ -113,15 +120,17 @@ export function DocsPage({
breadcrumb:
breadcrumb.enabled !== false ? (defaultSlots.breadcrumb ?? InlineBreadcrumb) : undefined,
footer: footer.enabled !== false ? (defaultSlots.footer ?? InlineFooter) : undefined,
toc: tocEnabled ? (defaultSlots.toc ?? InlineTOC) : undefined,
tocPopover: tocPopoverEnabled ? (defaultSlots.tocPopover ?? InlineTOCPopover) : undefined,
tocProvider: defaultSlots.tocProvider ?? TOCProvider,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: tocEnabled ? InlineTOC : undefined,
popover: tocPopoverEnabled ? InlineTOCPopover : undefined,
},
container: defaultSlots.container ?? Container,
};
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.toc?.popover && <slots.toc.popover />}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
@@ -129,15 +138,15 @@ export function DocsPage({
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
{slots.toc?.main && <slots.toc.main />}
</>
);
if (slots.tocProvider)
if (slots.toc)
content = (
<slots.tocProvider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
<slots.toc.provider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
{content}
</slots.tocProvider>
</slots.toc.provider>
);
return (
@@ -1,243 +0,0 @@
'use client';
import { TOCScrollArea, useTOCItems } from '@/components/toc';
import { useI18n } from '@/contexts/i18n';
import { useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useActiveAnchor } from 'fumadocs-core/toc';
import { ChevronDown } from 'lucide-react';
import {
ComponentProps,
useRef,
useState,
useEffectEvent,
useEffect,
useMemo,
use,
createContext,
ReactNode,
} from 'react';
import { useNotebookLayout } from '@/layouts/notebook/client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useNotebookLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -1,11 +1,30 @@
'use client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCScrollArea } from '@/components/toc';
import { I18nLabel } from '@/contexts/i18n';
import * as Base from '@/components/toc';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { Text } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { ChevronDown, Text } from 'lucide-react';
import {
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { useTreePath } from '@/contexts/tree';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useNotebookLayout } from '@/layouts/notebook/client';
export type TOCProviderProps = Base.TOCProviderProps;
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
@@ -43,10 +62,231 @@ export function TOC({ container, header, footer, style }: TOCProps) {
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
</Base.TOCScrollArea>
{footer}
</div>
);
}
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useNotebookLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useTOCItems();
const active = Base.useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
-2
View File
@@ -61,7 +61,6 @@
"./layouts/docs/page/slots/container": "./dist/layouts/docs/page/slots/container.js",
"./layouts/docs/page/slots/footer": "./dist/layouts/docs/page/slots/footer.js",
"./layouts/docs/page/slots/toc": "./dist/layouts/docs/page/slots/toc.js",
"./layouts/docs/page/slots/toc-popover": "./dist/layouts/docs/page/slots/toc-popover.js",
"./layouts/docs/slots/container": "./dist/layouts/docs/slots/container.js",
"./layouts/docs/slots/header": "./dist/layouts/docs/slots/header.js",
"./layouts/docs/slots/sidebar": "./dist/layouts/docs/slots/sidebar.js",
@@ -85,7 +84,6 @@
"./layouts/notebook/page/slots/container": "./dist/layouts/notebook/page/slots/container.js",
"./layouts/notebook/page/slots/footer": "./dist/layouts/notebook/page/slots/footer.js",
"./layouts/notebook/page/slots/toc": "./dist/layouts/notebook/page/slots/toc.js",
"./layouts/notebook/page/slots/toc-popover": "./dist/layouts/notebook/page/slots/toc-popover.js",
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
@@ -19,7 +19,9 @@ export function useTOCItems(): Primitive.TOCItemType[] {
return use(TOCContext);
}
export type TOCProviderProps = ComponentProps<typeof Primitive.AnchorProvider>;
export type TOCProviderProps = Primitive.AnchorProviderProps;
export const { useActiveAnchor, useActiveAnchors } = Primitive;
export function TOCProvider({ toc, children, ...props }: TOCProviderProps) {
return (
@@ -56,7 +58,7 @@ interface RefProps {
export function TocThumb({ containerRef, ...props }: ComponentProps<'div'> & RefProps) {
const thumbRef = useRef<HTMLDivElement>(null);
const active = Primitive.useActiveAnchors();
const active = useActiveAnchors();
function update(info: TocThumbType): void {
const element = thumbRef.current;
if (!element) return;
@@ -11,11 +11,16 @@ import {
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
import {
TOC,
TOCPopover,
TOCProvider,
type TOCProviderProps,
type TOCPopoverProps,
type TOCProps,
} from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
@@ -69,10 +74,12 @@ interface TableOfContentPopoverOptions extends TOCPopoverProps {
}
export interface DocsPageSlots {
toc?: FC<TOCProps>;
toc?: {
provider: FC<TOCProviderProps>;
main?: FC<TOCProps>;
popover?: FC<TOCPopoverProps>;
};
container?: FC<ComponentProps<'article'>>;
tocPopover?: FC<TOCPopoverProps>;
tocProvider?: FC<TOCProviderProps>;
footer?: FC<FooterProps>;
breadcrumb?: FC<BreadcrumbProps>;
}
@@ -114,15 +121,17 @@ export function DocsPage({
breadcrumb:
breadcrumb.enabled !== false ? (defaultSlots.breadcrumb ?? InlineBreadcrumb) : undefined,
footer: footer.enabled !== false ? (defaultSlots.footer ?? InlineFooter) : undefined,
toc: tocEnabled ? (defaultSlots.toc ?? InlineTOC) : undefined,
tocPopover: tocPopoverEnabled ? (defaultSlots.tocPopover ?? InlineTOCPopover) : undefined,
tocProvider: defaultSlots.tocProvider ?? TOCProvider,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: tocEnabled ? InlineTOC : undefined,
popover: tocPopoverEnabled ? InlineTOCPopover : undefined,
},
container: defaultSlots.container ?? Container,
};
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.toc?.popover && <slots.toc.popover />}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
@@ -130,15 +139,15 @@ export function DocsPage({
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
{slots.toc?.main && <slots.toc.main />}
</>
);
if (slots.tocProvider)
if (slots.toc)
content = (
<slots.tocProvider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
<slots.toc.provider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
{content}
</slots.tocProvider>
</slots.toc.provider>
);
return (
@@ -1,243 +0,0 @@
'use client';
import { TOCScrollArea, useTOCItems } from '@/components/toc';
import { useI18n } from '@/contexts/i18n';
import { useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useActiveAnchor } from 'fumadocs-core/toc';
import { ChevronDown } from 'lucide-react';
import {
ComponentProps,
useRef,
useState,
useEffectEvent,
useEffect,
useMemo,
use,
createContext,
ReactNode,
} from 'react';
import { useDocsLayout } from '@/layouts/docs/client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useDocsLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -1,11 +1,30 @@
'use client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCScrollArea } from '@/components/toc';
import { I18nLabel } from '@/contexts/i18n';
import * as Base from '@/components/toc';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { Text } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { ChevronDown, Text } from 'lucide-react';
import {
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { useTreePath } from '@/contexts/tree';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useDocsLayout } from '../..';
export type TOCProviderProps = Base.TOCProviderProps;
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
@@ -43,10 +62,231 @@ export function TOC({ container, header, footer, style }: TOCProps) {
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
</Base.TOCScrollArea>
{footer}
</div>
);
}
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useDocsLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useTOCItems();
const active = Base.useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -11,19 +11,26 @@ import {
} from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TOC, type TOCProps } from './slots/toc';
import { TOCPopover, type TOCPopoverProps } from './slots/toc-popover';
import {
TOC,
TOCPopover,
TOCProvider,
type TOCProviderProps,
type TOCPopoverProps,
type TOCProps,
} from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { TOCProvider, type TOCProviderProps } from '@/components/toc';
import { Container } from './slots/container';
import type { TOCItemType } from 'fumadocs-core/toc';
export interface DocsPageSlots {
toc?: FC<TOCProps>;
toc?: {
provider: FC<TOCProviderProps>;
main?: FC<TOCProps>;
popover?: FC<TOCPopoverProps>;
};
container?: FC<ComponentProps<'article'>>;
tocPopover?: FC<TOCPopoverProps>;
tocProvider?: FC<TOCProviderProps>;
footer?: FC<FooterProps>;
breadcrumb?: FC<BreadcrumbProps>;
}
@@ -113,15 +120,17 @@ export function DocsPage({
breadcrumb:
breadcrumb.enabled !== false ? (defaultSlots.breadcrumb ?? InlineBreadcrumb) : undefined,
footer: footer.enabled !== false ? (defaultSlots.footer ?? InlineFooter) : undefined,
toc: tocEnabled ? (defaultSlots.toc ?? InlineTOC) : undefined,
tocPopover: tocPopoverEnabled ? (defaultSlots.tocPopover ?? InlineTOCPopover) : undefined,
tocProvider: defaultSlots.tocProvider ?? TOCProvider,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: tocEnabled ? InlineTOC : undefined,
popover: tocPopoverEnabled ? InlineTOCPopover : undefined,
},
container: defaultSlots.container ?? Container,
};
let content = (
<>
{slots.tocPopover && <slots.tocPopover />}
{slots.toc?.popover && <slots.toc.popover />}
{slots.container && (
<slots.container {...containerProps}>
{slots.breadcrumb && <slots.breadcrumb />}
@@ -129,15 +138,15 @@ export function DocsPage({
{slots.footer && <slots.footer />}
</slots.container>
)}
{slots.toc && <slots.toc />}
{slots.toc?.main && <slots.toc.main />}
</>
);
if (slots.tocProvider)
if (slots.toc)
content = (
<slots.tocProvider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
<slots.toc.provider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
{content}
</slots.tocProvider>
</slots.toc.provider>
);
return (
@@ -1,243 +0,0 @@
'use client';
import { TOCScrollArea, useTOCItems } from '@/components/toc';
import { useI18n } from '@/contexts/i18n';
import { useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useActiveAnchor } from 'fumadocs-core/toc';
import { ChevronDown } from 'lucide-react';
import {
ComponentProps,
useRef,
useState,
useEffectEvent,
useEffect,
useMemo,
use,
createContext,
ReactNode,
} from 'react';
import { useNotebookLayout } from '@/layouts/notebook/client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useNotebookLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = useTOCItems();
const active = useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}
@@ -1,11 +1,30 @@
'use client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCScrollArea } from '@/components/toc';
import { I18nLabel } from '@/contexts/i18n';
import * as Base from '@/components/toc';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { Text } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { ChevronDown, Text } from 'lucide-react';
import {
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { useTreePath } from '@/contexts/tree';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useNotebookLayout } from '@/layouts/notebook/client';
export type TOCProviderProps = Base.TOCProviderProps;
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
@@ -43,10 +62,231 @@ export function TOC({ container, header, footer, style }: TOCProps) {
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
</Base.TOCScrollArea>
{footer}
</div>
);
}
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useNotebookLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useTOCItems();
const active = Base.useActiveAnchor();
const selected = useMemo(
() => items.findIndex((item) => active === item.url.slice(1)),
[items, active],
);
const path = useTreePath().at(-1);
const showItem = selected !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selected + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selected]?.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent
data-toc-popover-content=""
{...props}
className={cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className)}
>
{props.children}
</CollapsibleContent>
);
}