fumadocs-ui
Version:
The framework for building a documentation website in Next.js
155 lines (154 loc) • 9.2 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Fragment, useEffect, useMemo, useRef, useState, } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from '../../icons.js';
import Link from 'fumadocs-core/link';
import { cn } from '../../utils/cn.js';
import { useI18n } from '../../contexts/i18n.js';
import { useTreeContext, useTreePath } from '../../contexts/tree.js';
import { createContext, usePathname } from 'fumadocs-core/framework';
import { getBreadcrumbItemsFromPath, } from 'fumadocs-core/breadcrumb';
import { useNav } from '../../contexts/layout.js';
import { isActive } from '../../utils/is-active.js';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '../../components/ui/collapsible.js';
import { useSidebar } from '../../contexts/sidebar.js';
import { useTOCItems } from '../../components/layout/toc.js';
import { useActiveAnchor } from 'fumadocs-core/toc';
const TocPopoverContext = createContext('TocPopoverContext');
export function PageTOCPopoverTrigger(props) {
const { text } = useI18n();
const { open } = TocPopoverContext.use();
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 (_jsxs(CollapsibleTrigger, { ...props, className: cn('flex w-full h-(--fd-tocnav-height) 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', props.className), children: [_jsx(ProgressCircle, { value: (selected + 1) / Math.max(1, items.length), max: 1, className: cn('shrink-0', open && 'text-fd-primary') }), _jsxs("span", { className: "grid flex-1 *:my-auto *:row-start-1 *:col-start-1", children: [_jsx("span", { className: cn('truncate transition-all', open && 'text-fd-foreground', showItem && 'opacity-0 -translate-y-full pointer-events-none'), children: path?.name ?? text.toc }), _jsx("span", { className: cn('truncate transition-all', !showItem && 'opacity-0 translate-y-full pointer-events-none'), children: items[selected]?.title })] }), _jsx(ChevronDown, { className: cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180') })] }));
}
function clamp(input, min, max) {
if (input < min)
return min;
if (input > max)
return max;
return input;
}
function ProgressCircle({ value, strokeWidth = 2, size = 24, min = 0, max = 100, ...restSvgProps }) {
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 (_jsxs("svg", { role: "progressbar", viewBox: `0 0 ${size} ${size}`, "aria-valuenow": normalizedValue, "aria-valuemin": min, "aria-valuemax": max, ...restSvgProps, children: [_jsx("circle", { ...circleProps, className: "stroke-current/25" }), _jsx("circle", { ...circleProps, stroke: "currentColor", strokeDasharray: circumference, strokeDashoffset: circumference - progress, strokeLinecap: "round", transform: `rotate(-90 ${size / 2} ${size / 2})`, className: "transition-all" })] }));
}
export function PageTOCPopoverContent(props) {
return (_jsx(CollapsibleContent, { "data-toc-popover": "", ...props, className: cn('flex flex-col px-4 max-h-[50vh] md:px-6', props.className), children: props.children }));
}
export function PageTOCPopover(props) {
const ref = useRef(null);
const [open, setOpen] = useState(false);
const { collapsed } = useSidebar();
const { isTransparent } = useNav();
const onClick = useEffectEvent((e) => {
if (!open)
return;
if (ref.current && !ref.current.contains(e.target))
setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, [onClick]);
return (_jsx(TocPopoverContext.Provider, { value: useMemo(() => ({
open,
setOpen,
}), [setOpen, open]), children: _jsx(Collapsible, { open: open, onOpenChange: setOpen, asChild: true, children: _jsx("header", { ref: ref, id: "nd-tocnav", ...props, className: cn('fixed inset-x-0 z-10 border-b backdrop-blur-sm transition-colors xl:hidden', (!isTransparent || open) && 'bg-fd-background/80', open && 'shadow-lg', props.className), style: {
...props.style,
top: 'calc(var(--fd-banner-height) + var(--fd-nav-height))',
insetInlineStart: collapsed
? '0px'
: 'calc(var(--fd-sidebar-width) + var(--fd-layout-offset))',
}, children: props.children }) }) }));
}
export function PageLastUpdate({ date: value, ...props }) {
const { text } = useI18n();
const [date, setDate] = useState('');
useEffect(() => {
// to the timezone of client
setDate(new Date(value).toLocaleDateString());
}, [value]);
return (_jsxs("p", { ...props, className: cn('text-sm text-fd-muted-foreground', props.className), children: [text.lastUpdate, " ", date] }));
}
function scanNavigationList(tree) {
const list = [];
tree.forEach((node) => {
if (node.type === 'folder') {
if (node.index) {
list.push(node.index);
}
list.push(...scanNavigationList(node.children));
return;
}
if (node.type === 'page' && !node.external) {
list.push(node);
}
});
return list;
}
const listCache = new Map();
export function PageFooter({ items, ...props }) {
const { root } = useTreeContext();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items)
return items;
const cached = listCache.get(root.$id);
const list = cached ?? scanNavigationList(root.children);
listCache.set(root.$id, list);
const idx = list.findIndex((item) => isActive(item.url, pathname, false));
if (idx === -1)
return {};
return {
previous: list[idx - 1],
next: list[idx + 1],
};
}, [items, pathname, root]);
return (_jsxs("div", { ...props, className: cn('@container grid gap-4 pb-6', previous && next ? 'grid-cols-2' : 'grid-cols-1', props.className), children: [previous ? _jsx(FooterItem, { item: previous, index: 0 }) : null, next ? _jsx(FooterItem, { item: next, index: 1 }) : null] }));
}
function FooterItem({ item, index }) {
const { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
return (_jsxs(Link, { href: item.url, className: cn('flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full', index === 1 && 'text-end'), children: [_jsxs("div", { className: cn('inline-flex items-center gap-1.5 font-medium', index === 1 && 'flex-row-reverse'), children: [_jsx(Icon, { className: "-mx-1 size-4 shrink-0 rtl:rotate-180" }), _jsx("p", { children: item.name })] }), _jsx("p", { className: "text-fd-muted-foreground truncate", children: item.description ?? (index === 0 ? text.previousPage : text.nextPage) })] }));
}
export function PageBreadcrumb({ includeRoot = false, includeSeparator, includePage = false, ...props }) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage,
includeSeparator,
includeRoot,
});
}, [includePage, includeRoot, includeSeparator, path, root]);
if (items.length === 0)
return null;
return (_jsx("div", { ...props, className: cn('flex items-center gap-1.5 text-sm text-fd-muted-foreground', props.className), children: items.map((item, i) => {
const className = cn('truncate', i === items.length - 1 && 'text-fd-primary font-medium');
return (_jsxs(Fragment, { children: [i !== 0 && _jsx(ChevronRight, { className: "size-3.5 shrink-0" }), item.url ? (_jsx(Link, { href: item.url, className: cn(className, 'transition-opacity hover:opacity-80'), children: item.name })) : (_jsx("span", { className: className, children: item.name }))] }, i));
}) }));
}
export function PageTOC(props) {
return (_jsx("div", { id: "nd-toc", ...props, className: cn('sticky pb-2 pt-12 max-xl:hidden', props.className), style: {
...props.style,
top: 'calc(var(--fd-banner-height) + var(--fd-nav-height))',
height: 'calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))',
}, children: _jsx("div", { className: "flex h-full w-(--fd-toc-width) max-w-full flex-col pe-4", children: props.children }) }));
}