mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: refactor TOC slot
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-ui': patch
|
||||
'@fumadocs/base-ui': patch
|
||||
---
|
||||
|
||||
Refactor TOC slot
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user