UNPKG

fumadocs-ui

Version:

The framework for building a documentation website in Next.js

155 lines (154 loc) 9.2 kB
'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 }) })); }