mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: Support enabling Clerk-style TOC
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support Clerk-style TOC
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card';
|
||||
import { DocsPage, DocsBody } from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { type ReactNode } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { utils, type Page } from '@/utils/source';
|
||||
import { createMetadata } from '@/utils/metadata';
|
||||
import Preview from '@/components/preview';
|
||||
@@ -51,6 +51,7 @@ export default function Page({
|
||||
full={page.data.full}
|
||||
tableOfContent={{
|
||||
footer,
|
||||
style: 'clerk',
|
||||
}}
|
||||
tableOfContentPopover={{ footer }}
|
||||
>
|
||||
@@ -67,13 +68,8 @@ export default function Page({
|
||||
<page.data.exports.default
|
||||
components={{
|
||||
HeadlessOnly:
|
||||
params.slug[0] === 'headless'
|
||||
? ({ children }: { children: ReactNode }) => children
|
||||
: () => undefined,
|
||||
UIOnly:
|
||||
params.slug[0] === 'ui'
|
||||
? ({ children }: { children: ReactNode }) => children
|
||||
: () => undefined,
|
||||
params.slug[0] === 'headless' ? Fragment : () => undefined,
|
||||
UIOnly: params.slug[0] === 'ui' ? Fragment : () => undefined,
|
||||
}}
|
||||
/>
|
||||
{page.data.index ? <Category page={page} /> : null}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
import type { TOCItemType } from 'fumadocs-core/server';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import type { PosType } from '@/components/layout/toc';
|
||||
import { useTocThumb } from '@/utils/use-toc-thumb';
|
||||
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
|
||||
|
||||
export function ClerkTOCItems({
|
||||
export default function ClerkTOCItems({
|
||||
items,
|
||||
isMenu = false,
|
||||
}: {
|
||||
@@ -15,37 +16,13 @@ export function ClerkTOCItems({
|
||||
}): React.ReactElement {
|
||||
const { text } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<PosType>([0, 0]);
|
||||
const active = Primitive.useActiveAnchors();
|
||||
const pos = useTocThumb(containerRef);
|
||||
const [svg, setSvg] = useState<{
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (active.length === 0 || !container || container.clientHeight === 0) {
|
||||
setPos([0, 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
let upper = Number.MAX_VALUE,
|
||||
lower = 0;
|
||||
|
||||
for (const item of active) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${item}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
upper = Math.min(upper, element.offsetTop);
|
||||
lower = Math.max(lower, element.offsetTop + element.clientHeight);
|
||||
}
|
||||
|
||||
setPos([upper, lower - upper]);
|
||||
}, [active]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const container = containerRef.current;
|
||||
@@ -56,18 +33,18 @@ export function ClerkTOCItems({
|
||||
h = 0;
|
||||
const d: string[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${item.url.slice(1)}"]`,
|
||||
`a[href="#${items[i].url.slice(1)}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const offset = getLineOffset(item.depth) + 1,
|
||||
top = i === 0 ? element.offsetTop : element.offsetTop + 8,
|
||||
const styles = getComputedStyle(element);
|
||||
const offset = getLineOffset(items[i].depth) + 1,
|
||||
top = element.offsetTop + parseFloat(styles.paddingTop),
|
||||
bottom =
|
||||
i === items.length - 1
|
||||
? element.offsetTop + element.clientHeight
|
||||
: element.offsetTop + element.clientHeight - 8;
|
||||
element.offsetTop +
|
||||
element.clientHeight -
|
||||
parseFloat(styles.paddingBottom);
|
||||
|
||||
w = Math.max(offset, w);
|
||||
h = Math.max(h, bottom);
|
||||
@@ -172,7 +149,7 @@ function TOCItem({
|
||||
style={{
|
||||
paddingInlineStart: `${getItemOffset(item.depth)}px`,
|
||||
}}
|
||||
className="relative py-2 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary"
|
||||
className="relative py-2 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
|
||||
>
|
||||
{offset !== getLineOffset(upper) ? (
|
||||
<svg
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { ChevronRight, Text } from 'lucide-react';
|
||||
import type { TOCItemType } from 'fumadocs-core/server';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { type ReactElement, type ReactNode, useMemo, useRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import {
|
||||
@@ -16,27 +9,26 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { ClerkTOCItems } from '@/components/layout/toc-clerk';
|
||||
import { useTocThumb } from '@/utils/use-toc-thumb';
|
||||
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
|
||||
|
||||
export type PosType = [top: number, height: number];
|
||||
|
||||
export interface TOCProps {
|
||||
items: TOCItemType[];
|
||||
|
||||
/**
|
||||
* Custom content in TOC container, before the main TOC
|
||||
*/
|
||||
header?: ReactNode;
|
||||
|
||||
/**
|
||||
* Custom content in TOC container, after the main TOC
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const TocProvider = Primitive.AnchorProvider;
|
||||
|
||||
export function Toc({ items, header, footer }: TOCProps): ReactElement {
|
||||
export function Toc({ header, footer, children }: TOCProps): ReactElement {
|
||||
const { text } = useI18n();
|
||||
|
||||
return (
|
||||
@@ -49,13 +41,18 @@ export function Toc({ items, header, footer }: TOCProps): ReactElement {
|
||||
<Text className="size-4" />
|
||||
{text.toc}
|
||||
</h3>
|
||||
<ClerkTOCItems items={items} />
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
|
||||
export function TocPopover({
|
||||
header,
|
||||
footer,
|
||||
items,
|
||||
children,
|
||||
}: TOCProps & { items: TOCItemType[] }): ReactElement {
|
||||
const { text } = useI18n();
|
||||
const active = Primitive.useActiveAnchor();
|
||||
const current = useMemo(() => {
|
||||
@@ -83,15 +80,14 @@ export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
|
||||
data-toc-popover=""
|
||||
>
|
||||
{header}
|
||||
<ClerkTOCItems items={items} isMenu />
|
||||
{children}
|
||||
{footer}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- test
|
||||
function TOCItems({
|
||||
export function TOCItems({
|
||||
items,
|
||||
isMenu = false,
|
||||
}: {
|
||||
@@ -100,31 +96,7 @@ function TOCItems({
|
||||
}): React.ReactElement {
|
||||
const { text } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<PosType>([0, 0]);
|
||||
const active = Primitive.useActiveAnchors();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (active.length === 0 || !container || container.clientHeight === 0) {
|
||||
setPos([0, 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
let upper = Number.MAX_VALUE,
|
||||
lower = 0;
|
||||
|
||||
for (const item of active) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${item}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
upper = Math.min(upper, element.offsetTop);
|
||||
lower = Math.max(lower, element.offsetTop + element.clientHeight);
|
||||
}
|
||||
|
||||
setPos([upper, lower - upper]);
|
||||
}, [active]);
|
||||
const pos = useTocThumb(containerRef);
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -166,7 +138,7 @@ function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
|
||||
<Primitive.TOCItem
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'py-1.5 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary',
|
||||
'py-1.5 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
|
||||
item.depth <= 2 && 'ps-3.5',
|
||||
item.depth === 3 && 'ps-6',
|
||||
item.depth >= 4 && 'ps-8',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type TableOfContents } from 'fumadocs-core/server';
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { replaceOrDefault } from './utils/shared';
|
||||
import { cn } from './utils/cn';
|
||||
import type { BreadcrumbProps, FooterProps, TOCProps } from './page.client';
|
||||
@@ -9,13 +10,21 @@ declare const {
|
||||
TocPopover,
|
||||
Breadcrumb,
|
||||
Footer,
|
||||
TOCItems,
|
||||
TocProvider,
|
||||
LastUpdate,
|
||||
}: typeof import('./page.client');
|
||||
|
||||
type TableOfContentOptions = Omit<TOCProps, 'items'> & {
|
||||
const ClerkTOCItems = dynamic(() => import('@/components/layout/toc-clerk'));
|
||||
|
||||
type TableOfContentOptions = Omit<TOCProps, 'items' | 'children'> & {
|
||||
enabled: boolean;
|
||||
component: ReactNode;
|
||||
|
||||
/**
|
||||
* @defaultValue 'normal'
|
||||
*/
|
||||
style?: 'normal' | 'clerk';
|
||||
};
|
||||
|
||||
interface BreadcrumbOptions extends BreadcrumbProps {
|
||||
@@ -95,7 +104,13 @@ export function DocsPage({
|
||||
items={toc}
|
||||
header={tocPopoverOptions.header}
|
||||
footer={tocPopoverOptions.footer}
|
||||
/>
|
||||
>
|
||||
{tocPopoverOptions.style === 'clerk' ? (
|
||||
<ClerkTOCItems items={toc} isMenu />
|
||||
) : (
|
||||
<TOCItems items={toc} isMenu />
|
||||
)}
|
||||
</TocPopover>
|
||||
</div>,
|
||||
)}
|
||||
<article className="flex flex-1 flex-col gap-6 px-4 pt-10 md:px-6 md:pt-12">
|
||||
@@ -108,11 +123,13 @@ export function DocsPage({
|
||||
</div>
|
||||
{replaceOrDefault(
|
||||
tocOptions,
|
||||
<Toc
|
||||
items={toc}
|
||||
header={tocOptions.header}
|
||||
footer={tocOptions.footer}
|
||||
/>,
|
||||
<Toc header={tocOptions.header} footer={tocOptions.footer}>
|
||||
{tocOptions.style === 'clerk' ? (
|
||||
<ClerkTOCItems items={toc} />
|
||||
) : (
|
||||
<TOCItems items={toc} />
|
||||
)}
|
||||
</Toc>,
|
||||
)}
|
||||
</TocProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RefObject, useLayoutEffect, useState } from 'react';
|
||||
import * as Primitive from 'fumadocs-core/toc';
|
||||
|
||||
export type TOCThumb = [top: number, height: number];
|
||||
|
||||
export function useTocThumb(containerRef: RefObject<HTMLElement>): TOCThumb {
|
||||
const active = Primitive.useActiveAnchors();
|
||||
const [pos, setPos] = useState<TOCThumb>([0, 0]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (active.length === 0 || !container || container.clientHeight === 0) {
|
||||
setPos([0, 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
let upper = Number.MAX_VALUE,
|
||||
lower = 0;
|
||||
|
||||
for (const item of active) {
|
||||
const element: HTMLElement | null = container.querySelector(
|
||||
`a[href="#${item}"]`,
|
||||
);
|
||||
if (!element) continue;
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
upper = Math.min(
|
||||
upper,
|
||||
element.offsetTop + parseFloat(styles.paddingTop),
|
||||
);
|
||||
lower = Math.max(
|
||||
lower,
|
||||
element.offsetTop +
|
||||
element.clientHeight -
|
||||
parseFloat(styles.paddingBottom),
|
||||
);
|
||||
}
|
||||
|
||||
setPos([upper, lower - upper]);
|
||||
}, [active, containerRef]);
|
||||
|
||||
return pos;
|
||||
}
|
||||
Reference in New Issue
Block a user