UI: Support fumadocs-ui/layouts/docs-notebook layout

This commit is contained in:
Fuma Nama
2024-11-10 22:44:55 +08:00
parent d3faa9682b
commit 64defe0493
19 changed files with 639 additions and 349 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Support `fumadocs-ui/layouts/docs-notebook` layout
+1 -15
View File
@@ -14,21 +14,7 @@ export default {
presets: [
createPreset({
addGlobalColors: true,
preset: {
...presets.default,
dark: {
...presets.default.dark,
background: '0 0% 2%',
foreground: '0 0% 98%',
popover: '0 0% 4%',
card: '0 0% 4%',
muted: '0 0% 8%',
border: '0 0% 14%',
accent: '0 0% 15%',
'accent-foreground': '0 0% 100%',
'muted-foreground': '0 0% 60%',
},
},
preset: 'neutral',
}),
],
theme: {
@@ -1,54 +0,0 @@
'use client';
import { ChevronRight } from 'lucide-react';
import {
type BreadcrumbOptions,
getBreadcrumbItemsFromPath,
} from 'fumadocs-core/breadcrumb';
import Link from 'next/link';
import { Fragment, useMemo } from 'react';
import { useTreeContext, useTreePath } from '@/contexts/tree';
export interface BreadcrumbProps
extends Omit<BreadcrumbOptions, 'includePage'> {
/**
* Show the full path to the current page
*
* @defaultValue false
*/
full?: boolean;
}
export function Breadcrumb({ full = false, ...options }: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage: full,
...options,
});
}, [full, options, path, root]);
if (items.length === 0) return null;
return (
<div className="-mb-3 flex flex-row items-center gap-1 text-sm font-medium text-fd-muted-foreground">
{items.map((item, i) => (
<Fragment key={i}>
{i !== 0 && (
<ChevronRight className="size-4 shrink-0 rtl:rotate-180" />
)}
{item.url ? (
<Link
href={item.url}
className="truncate hover:text-fd-accent-foreground"
>
{item.name}
</Link>
) : (
<span className="truncate">{item.name}</span>
)}
</Fragment>
))}
</div>
);
}
+3 -3
View File
@@ -34,13 +34,13 @@ export const NavContext = createContext<NavContextType>({
export function NavProvider({
transparentMode = 'none',
children,
}: NavProviderProps & { children: ReactNode }): ReactNode {
}: NavProviderProps & { children: ReactNode }) {
const [transparent, setTransparent] = useState(transparentMode !== 'none');
useEffect(() => {
if (transparentMode !== 'top') return;
const listener = (): void => {
const listener = () => {
setTransparent(window.scrollY < 10);
};
@@ -62,7 +62,7 @@ export function Title({
title,
url,
...props
}: TitleProps & Omit<LinkProps, 'title'>): React.ReactElement {
}: TitleProps & Omit<LinkProps, 'title'>) {
const { locale } = useI18n();
return (
@@ -6,6 +6,7 @@ import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import { TocThumb } from '@/components/layout/toc-thumb';
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
import { TocItemsEmpty } from '@/components/layout/toc';
export default function ClerkTOCItems({
items,
@@ -70,12 +71,7 @@ export default function ClerkTOCItems({
};
}, [items]);
if (items.length === 0)
return (
<div className="rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground">
{text.tocNoHeadings}
</div>
);
if (items.length === 0) return <TocItemsEmpty />;
return (
<ScrollArea className={cn('flex flex-col', isMenu && '-ms-3')}>
+12 -8
View File
@@ -27,7 +27,7 @@ export function Toc(props: HTMLAttributes<HTMLDivElement>) {
{...props}
data-toc=""
className={cn(
'sticky top-fd-layout-top h-[var(--fd-toc-height)] flex-1 pb-2 pe-2 pt-12 max-lg:hidden',
'sticky top-fd-layout-top h-[var(--fd-toc-height)] flex-1 pb-2 pt-12 max-lg:hidden',
props.className,
)}
style={
@@ -43,6 +43,16 @@ export function Toc(props: HTMLAttributes<HTMLDivElement>) {
);
}
export function TocItemsEmpty() {
const { text } = useI18n();
return (
<div className="rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground">
{text.tocNoHeadings}
</div>
);
}
export function TOCItems({
items,
isMenu = false,
@@ -50,16 +60,10 @@ export function TOCItems({
items: TOCItemType[];
isMenu?: boolean;
}) {
const { text } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
if (items.length === 0)
return (
<div className="rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground">
{text.tocNoHeadings}
</div>
);
if (items.length === 0) return <TocItemsEmpty />;
return (
<ScrollArea className={cn('flex flex-col', isMenu && '-ms-3')}>
+6 -2
View File
@@ -1,5 +1,6 @@
'use client';
import {
type ComponentType,
createContext,
type ReactNode,
useContext,
@@ -43,7 +44,7 @@ export interface SearchProviderProps {
*
* It receives the `open` and `onOpenChange` prop, can be lazy loaded with `next/dynamic`
*/
SearchDialog: React.ComponentType<SharedProps>;
SearchDialog: ComponentType<SharedProps>;
/**
* Additional props to the dialog
@@ -97,7 +98,7 @@ export function SearchProvider({
},
],
links,
}: SearchProviderProps): React.ReactElement {
}: SearchProviderProps) {
const [isOpen, setIsOpen] = useState(preload ? false : undefined);
useEffect(() => {
@@ -138,6 +139,9 @@ export function SearchProvider({
);
}
/**
* Show children only when search is enabled via React Context
*/
export function SearchOnly({ children }: { children: ReactNode }) {
const search = useSearchContext();
@@ -0,0 +1,26 @@
'use client';
import { cn } from '@/utils/cn';
import type { HTMLAttributes } from 'react';
import { useSidebar } from '@/contexts/sidebar';
export function LayoutBody(props: HTMLAttributes<HTMLElement>) {
const { collapsed } = useSidebar();
return (
<main
id="nd-docs-layout"
{...props}
className={cn('flex w-full flex-1 flex-row', props.className)}
style={
{
...props.style,
'--fd-content-width': collapsed
? '100vw'
: 'calc(min(100vw, var(--fd-layout-width)) - var(--fd-sidebar-width))',
} as object
}
>
{props.children}
</main>
);
}
+263
View File
@@ -0,0 +1,263 @@
import { Fragment, type HTMLAttributes, type ReactNode } from 'react';
import {
type BaseLayoutProps,
getLinks,
type SharedNavProps,
} from '@/layouts/shared';
import {
CollapsibleSidebar,
Sidebar,
SidebarCollapseTrigger,
SidebarFooter,
SidebarHeader,
SidebarViewport,
} from '@/layouts/docs/sidebar';
import { notFound } from 'next/navigation';
import { RootToggle } from '@/components/layout/root-toggle';
import { TreeContextProvider } from '@/contexts/tree';
import { NavProvider, Title } from '@/components/layout/nav';
import { Navbar, NavbarSidebarTrigger } from '@/layouts/docs/navbar';
import { SearchOnly } from '@/contexts/search';
import {
LargeSearchToggle,
SearchToggle,
} from '@/components/layout/search-toggle';
import { cn } from '@/utils/cn';
import Link from 'next/link';
import { buttonVariants } from '@/components/ui/button';
import { ChevronDown, Languages } from 'lucide-react';
import { BaseLinkItem, type LinkItemType } from '@/layouts/links';
import { LanguageToggle } from '@/components/layout/language-toggle';
import { ThemeToggle } from '@/components/layout/theme-toggle';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
getSidebarTabsFromOptions,
SidebarLinkItem,
type SidebarOptions,
} from '@/layouts/docs/shared';
import type { PageTree } from 'fumadocs-core/server';
import { SidebarItems } from '@/layouts/docs/sidebar-items';
import { LayoutBody } from '@/layouts/docs-notebook.client';
export interface DocsLayoutProps extends Omit<BaseLayoutProps, 'nav'> {
tree: PageTree.Root;
nav: Omit<Exclude<BaseLayoutProps['nav'], undefined>, 'enabled'>;
sidebar?: Omit<Partial<SidebarOptions>, 'component' | 'enabled'>;
containerProps?: HTMLAttributes<HTMLDivElement>;
}
export function DocsLayout({
nav: { transparentMode, ...nav } = {},
sidebar: {
collapsible: sidebarCollapsible = true,
tabs: tabOptions,
banner: sidebarBanner,
footer: sidebarFooter,
components: sidebarComponents,
...sidebar
} = {},
i18n = false,
...props
}: DocsLayoutProps): ReactNode {
const links = getLinks(props.links ?? [], props.githubUrl);
const Aside = sidebarCollapsible ? CollapsibleSidebar : Sidebar;
if (props.tree === undefined) notFound();
const tabs = getSidebarTabsFromOptions(tabOptions, props.tree) ?? [];
return (
<TreeContextProvider tree={props.tree}>
<NavProvider transparentMode={transparentMode}>
<LayoutBody
{...props.containerProps}
className={cn(
'[--fd-nav-height:3.5rem] md:[--fd-sidebar-width:260px] lg:[--fd-toc-width:240px] xl:[--fd-toc-width:260px]',
props.containerProps?.className,
)}
>
<Aside
{...sidebar}
className={cn('md:[--fd-nav-height:0px]', sidebar.className)}
>
<SidebarHeader>
<SidebarHeaderItems nav={nav} links={links}>
{nav.children}
{sidebarCollapsible ? (
<SidebarCollapseTrigger className="ms-auto text-fd-muted-foreground" />
) : null}
</SidebarHeaderItems>
{sidebarBanner}
{tabs.length > 0 ? (
<RootToggle options={tabs} className="md:-mx-2" />
) : null}
</SidebarHeader>
<SidebarViewport>
<div className="px-4 pt-4 empty:hidden md:px-3 lg:hidden">
{links.map((item, i) => (
<SidebarLinkItem key={i} item={item} />
))}
</div>
<div className="p-4 md:px-3">
<SidebarItems components={sidebarComponents} />
</div>
</SidebarViewport>
<SidebarFooter>{sidebarFooter}</SidebarFooter>
</Aside>
<div className="w-full min-w-0 max-w-[var(--fd-content-width)] [--fd-nav-height:3.5rem]">
<DocsNavbar
nav={nav}
links={links}
i18n={i18n}
sidebarCollapsible={sidebarCollapsible}
/>
<div className="flex flex-row">{props.children}</div>
</div>
<div
className="min-w-[var(--fd-sidebar-width)] flex-1"
style={{
marginInlineStart: 'calc(var(--fd-sidebar-width) * -1)',
}}
/>
</LayoutBody>
</NavProvider>
</TreeContextProvider>
);
}
function DocsNavbar({
sidebarCollapsible,
links,
nav,
i18n,
}: {
nav: DocsLayoutProps['nav'];
sidebarCollapsible: boolean;
i18n: boolean;
links: LinkItemType[];
}) {
return (
<Navbar className="h-14">
{sidebarCollapsible ? (
<SidebarCollapseTrigger className="me-2 data-[collapsed=false]:hidden max-md:hidden" />
) : null}
<SearchOnly>
<LargeSearchToggle className="w-full max-w-[240px] rounded-lg max-md:hidden" />
</SearchOnly>
<Title url={nav.url} title={nav.title} className="md:hidden" />
<div className="ms-4 flex flex-1 flex-row items-center gap-6">
{links
.filter((item) => item.type !== 'icon')
.map((item, i) => (
<NavbarLinkItem
key={i}
item={item}
className="text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground max-lg:hidden"
/>
))}
{nav.children}
</div>
<SearchOnly>
<SearchToggle className="md:hidden" />
</SearchOnly>
<NavbarSidebarTrigger className="-me-2 md:hidden" />
{links
.filter((item) => item.type === 'icon')
.map((item, i) => (
<BaseLinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon', color: 'ghost' }),
'text-fd-muted-foreground max-lg:hidden',
)}
aria-label={item.label}
>
{item.icon}
</BaseLinkItem>
))}
{i18n ? (
<LanguageToggle className="me-1.5">
<Languages className="size-5" />
</LanguageToggle>
) : null}
<ThemeToggle className="max-md:hidden" />
</Navbar>
);
}
function NavbarLinkItem({
item,
...props
}: { item: LinkItemType } & HTMLAttributes<HTMLElement>) {
if (item.type === 'menu') {
return (
<Popover>
<PopoverTrigger
{...props}
className={cn('inline-flex items-center gap-1.5', props.className)}
>
{item.text}
<ChevronDown className="size-3" />
</PopoverTrigger>
<PopoverContent className="flex flex-col">
{item.items.map((child, i) => {
if (child.type === 'custom')
return <Fragment key={i}>{child.children}</Fragment>;
return (
<BaseLinkItem
key={i}
item={child}
className="inline-flex items-center gap-2 rounded-md p-2 text-start hover:bg-fd-accent hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4"
>
{child.icon}
{child.text}
</BaseLinkItem>
);
})}
</PopoverContent>
</Popover>
);
}
if (item.type === 'custom') return item.children;
return (
<BaseLinkItem item={item} {...props}>
{item.text}
</BaseLinkItem>
);
}
function SidebarHeaderItems({
links,
nav,
children,
}: SharedNavProps & {
nav: DocsLayoutProps['nav'];
links: LinkItemType[];
children: ReactNode;
}) {
const isEmpty = !nav.title && !nav.children && links.length === 0;
if (isEmpty) return null;
return (
<div className="flex flex-row items-center max-md:hidden">
{nav.title ? (
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 py-1 font-medium"
>
{nav.title}
</Link>
) : null}
{children}
</div>
);
}
+3 -104
View File
@@ -4,7 +4,6 @@ import { ChevronDown } from 'lucide-react';
import {
type ButtonHTMLAttributes,
type HTMLAttributes,
type ReactNode,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
@@ -22,17 +21,6 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import type { PageTree } from 'fumadocs-core/server';
import type { SidebarComponents } from '@/layouts/docs';
import {
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
SidebarSeparator,
} from '@/layouts/docs/sidebar';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { buttonVariants } from '@/components/ui/button';
const itemVariants = cva(
@@ -55,7 +43,9 @@ export function LinksMenu({ items, ...props }: LinksMenuProps) {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger {...props} />
<PopoverContent className="flex flex-col p-1">
{items?.map((item, i) => <MenuItem key={i} item={item} />)}
{items.map((item, i) => (
<MenuItem key={i} item={item} />
))}
</PopoverContent>
</Popover>
);
@@ -114,94 +104,3 @@ export function MenuItem({ item, ...props }: MenuItemProps) {
</BaseLinkItem>
);
}
export function SidebarItems(props: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return (
<div className="px-2 py-4 md:px-3">
{renderSidebarList(root.children, 1, props?.components ?? {})}
</div>
);
}
function PageTreeFolder({
item,
children,
level,
}: {
item: PageTree.Folder;
level: number;
children: ReactNode;
}) {
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={item.defaultOpen || path.includes(item)}
level={level + 1}
>
{children}
</SidebarFolder>
);
}
function renderSidebarList(
items: PageTree.Node[],
level: number,
customComps: Partial<SidebarComponents>,
): ReactNode[] {
const { Separator, Item, Folder } = customComps;
return items.map((item, i) => {
const id = `${item.type}_${i.toString()}`;
switch (item.type) {
case 'separator':
return Separator ? (
<Separator key={id} item={item} />
) : (
<SidebarSeparator key={id}>{item.name}</SidebarSeparator>
);
case 'folder':
return Folder ? (
<Folder key={id} item={item} level={level + 1} />
) : (
<PageTreeFolder key={id} item={item} level={level + 1}>
{item.index ? (
<SidebarFolderLink
href={item.index.url}
external={item.index.external}
>
{item.icon}
{item.name}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.name}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>
{renderSidebarList(item.children, level + 1, customComps)}
</SidebarFolderContent>
</PageTreeFolder>
);
default:
return Item ? (
<Item key={item.url} item={item} />
) : (
<SidebarItem
key={item.url}
href={item.url}
external={item.external}
icon={item.icon}
>
{item.name}
</SidebarItem>
);
}
});
}
+20 -101
View File
@@ -1,5 +1,5 @@
import type { PageTree } from 'fumadocs-core/server';
import { type ReactNode, type HTMLAttributes, Fragment, type FC } from 'react';
import { type ReactNode, type HTMLAttributes } from 'react';
import Link from 'next/link';
import { Languages, MoreHorizontal } from 'lucide-react';
import { notFound } from 'next/navigation';
@@ -11,13 +11,7 @@ import {
SidebarFooter,
SidebarHeader,
SidebarCollapseTrigger,
type SidebarProps,
SidebarViewport,
SidebarItem,
SidebarFolder,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarFolderContent,
} from '@/layouts/docs/sidebar';
import { replaceOrDefault, type SharedNavProps } from '@/layouts/shared';
import {
@@ -26,13 +20,13 @@ import {
BaseLinkItem,
} from '@/layouts/links';
import { getSidebarTabs, type TabOptions } from '@/utils/get-sidebar-tabs';
import { type Option, RootToggle } from '@/components/layout/root-toggle';
import { RootToggle } from '@/components/layout/root-toggle';
import { type BaseLayoutProps, getLinks } from './shared';
import {
LanguageToggle,
LanguageToggleText,
} from '@/components/layout/language-toggle';
import { SidebarItems, LinksMenu } from '@/layouts/docs.client';
import { LinksMenu } from '@/layouts/docs.client';
import { TreeContextProvider } from '@/contexts/tree';
import { NavProvider, Title } from '@/components/layout/nav';
import { ThemeToggle } from '@/components/layout/theme-toggle';
@@ -42,35 +36,12 @@ import {
SearchToggle,
} from '@/components/layout/search-toggle';
import { SearchOnly } from '@/contexts/search';
interface SidebarOptions extends SidebarProps {
enabled: boolean;
component: ReactNode;
collapsible?: boolean;
components?: Partial<SidebarComponents>;
/**
* Root Toggle options
*/
tabs?: Option[] | TabOptions | false;
banner?: ReactNode;
footer?: ReactNode;
/**
* Hide search trigger
*
* @defaultValue false
*/
hideSearch?: boolean;
}
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number }>;
Separator: FC<{ item: PageTree.Separator }>;
}
import {
getSidebarTabsFromOptions,
SidebarLinkItem,
type SidebarOptions,
} from '@/layouts/docs/shared';
import { SidebarItems } from '@/layouts/docs/sidebar-items';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
@@ -104,14 +75,7 @@ export function DocsLayout({
const Aside = collapsible ? CollapsibleSidebar : Sidebar;
if (props.tree === undefined) notFound();
let tabs: Option[] = [];
if (Array.isArray(tabOptions)) {
tabs = tabOptions;
} else if (typeof tabOptions === 'object') {
tabs = getSidebarTabs(props.tree, tabOptions);
} else if (tabOptions !== false) {
tabs = getSidebarTabs(props.tree);
}
const tabs = getSidebarTabsFromOptions(tabOptions, props.tree) ?? [];
return (
<TreeContextProvider tree={props.tree}>
@@ -126,7 +90,7 @@ export function DocsLayout({
<SearchOnly>
<SearchToggle />
</SearchOnly>
<NavbarSidebarTrigger />
<NavbarSidebarTrigger className="-me-2 md:hidden" />
</Navbar>,
nav,
)}
@@ -134,13 +98,16 @@ export function DocsLayout({
id="nd-docs-layout"
{...props.containerProps}
className={cn(
'flex flex-1 flex-row',
'flex flex-1 flex-row md:[--fd-sidebar-width:260px] lg:[--fd-toc-width:240px] xl:[--fd-toc-width:260px]',
!navReplace && navEnabled
? '[--fd-nav-height:3.5rem] md:[--fd-nav-height:0px]'
: null,
props.containerProps?.className,
)}
>
{collapsible ? (
<SidebarCollapseTrigger className="fixed bottom-3 start-2 z-40 transition-opacity data-[collapsed=false]:pointer-events-none data-[collapsed=false]:opacity-0 max-md:hidden" />
) : null}
{replaceOrDefault(
{ enabled: sidebarEnabled, component: sidebarReplace },
<Aside {...sidebar}>
@@ -159,10 +126,12 @@ export function DocsLayout({
{links
.filter((v) => v.type !== 'icon')
.map((item, i) => (
<Fragment key={i}>{renderLinkItem(item)}</Fragment>
<SidebarLinkItem key={i} item={item} />
))}
</div>
<SidebarItems components={sidebarComponents} />
<div className="px-2 py-4 md:px-3">
<SidebarItems components={sidebarComponents} />
</div>
</SidebarViewport>
<SidebarFooter>
<SidebarFooterItems
@@ -186,60 +155,10 @@ export function DocsLayout({
);
}
function renderLinkItem(item: LinkItemType): ReactNode {
if (!item.type || item.type === 'main' || item.type === 'icon')
return (
<SidebarItem href={item.url} icon={item.icon} external={item.external}>
{item.text}
</SidebarItem>
);
if (item.type === 'menu')
return (
<SidebarFolder level={1}>
{item.url ? (
<SidebarFolderLink href={item.url}>
{item.icon}
{item.text}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.text}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>
{item.items.map((child, i) => (
<Fragment key={i}>{renderLinkItem(child)}</Fragment>
))}
</SidebarFolderContent>
</SidebarFolder>
);
if (item.type === 'button') {
return (
<BaseLinkItem
item={item}
className={cn(
buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
}),
)}
>
{item.icon}
{item.text}
</BaseLinkItem>
);
}
if (item.type === 'custom') return item.children;
}
function SidebarHeaderItems({
links,
...props
}: SharedNavProps & { links: LinkItemType[] }): ReactNode {
}: SharedNavProps & { links: LinkItemType[] }) {
const isEmpty = !props.title && !props.children && links.length === 0;
if (isEmpty) return null;
+11 -4
View File
@@ -1,6 +1,10 @@
'use client';
import { useSidebar } from '@/contexts/sidebar';
import { type HTMLAttributes, useContext } from 'react';
import {
type ButtonHTMLAttributes,
type HTMLAttributes,
useContext,
} from 'react';
import { NavContext } from '@/components/layout/nav';
import { cn } from '@/utils/cn';
import { SidebarTrigger } from 'fumadocs-core/sidebar';
@@ -16,7 +20,7 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
id="nd-subnav"
{...props}
className={cn(
'sticky top-[var(--fd-banner-height)] z-40 flex flex-row items-center border-b border-fd-foreground/10 px-4 transition-colors',
'sticky top-[var(--fd-banner-height)] z-30 flex flex-row items-center border-b border-fd-foreground/10 px-4 transition-colors',
(!isTransparent || open) && 'bg-fd-background/80 backdrop-blur-lg',
props.className,
)}
@@ -26,17 +30,20 @@ export function Navbar(props: HTMLAttributes<HTMLElement>) {
);
}
export function NavbarSidebarTrigger() {
export function NavbarSidebarTrigger(
props: ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { open } = useSidebar();
return (
<SidebarTrigger
{...props}
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon',
className: '-me-2 md:hidden',
}),
props.className,
)}
>
{open ? <X /> : <Menu />}
+106
View File
@@ -0,0 +1,106 @@
import { BaseLinkItem, type LinkItemType } from '@/layouts/links';
import {
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
type SidebarProps,
} from '@/layouts/docs/sidebar';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import type { PageTree } from 'fumadocs-core/server';
import { getSidebarTabs, type TabOptions } from '@/utils/get-sidebar-tabs';
import type { FC, ReactNode } from 'react';
import type { Option } from '@/components/layout/root-toggle';
export interface SidebarOptions extends SidebarProps {
enabled: boolean;
component: ReactNode;
collapsible?: boolean;
components?: Partial<SidebarComponents>;
/**
* Root Toggle options
*/
tabs?: Option[] | TabOptions | false;
banner?: ReactNode;
footer?: ReactNode;
/**
* Hide search trigger
*
* @defaultValue false
*/
hideSearch?: boolean;
}
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number }>;
Separator: FC<{ item: PageTree.Separator }>;
}
export function SidebarLinkItem({ item }: { item: LinkItemType }) {
if (item.type === 'menu')
return (
<SidebarFolder level={1}>
{item.url ? (
<SidebarFolderLink href={item.url}>
{item.icon}
{item.text}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.text}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>
{item.items.map((child, i) => (
<SidebarLinkItem key={i} item={child} />
))}
</SidebarFolderContent>
</SidebarFolder>
);
if (item.type === 'button') {
return (
<BaseLinkItem
item={item}
className={cn(
buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
}),
)}
>
{item.icon}
{item.text}
</BaseLinkItem>
);
}
if (item.type === 'custom') return item.children;
return (
<SidebarItem href={item.url} icon={item.icon} external={item.external}>
{item.text}
</SidebarItem>
);
}
export function getSidebarTabsFromOptions(
options: SidebarOptions['tabs'],
tree: PageTree.Root,
) {
if (Array.isArray(options)) {
return options;
} else if (typeof options === 'object') {
return getSidebarTabs(tree, options);
} else if (options !== false) {
return getSidebarTabs(tree);
}
}
@@ -0,0 +1,101 @@
'use client';
import type { PageTree } from 'fumadocs-core/server';
import { type ReactNode, useMemo } from 'react';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import {
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
SidebarSeparator,
} from '@/layouts/docs/sidebar';
import type { SidebarComponents } from '@/layouts/docs/shared';
function PageTreeFolder({
item,
children,
level,
}: {
item: PageTree.Folder;
level: number;
children: ReactNode;
}) {
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={item.defaultOpen || path.includes(item)}
level={level + 1}
>
{item.index ? (
<SidebarFolderLink href={item.index.url} external={item.index.external}>
{item.icon}
{item.name}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger>
{item.icon}
{item.name}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>{children}</SidebarFolderContent>
</SidebarFolder>
);
}
function renderSidebarList(
items: PageTree.Node[],
level: number,
customComps: Partial<SidebarComponents>,
): ReactNode[] {
const { Separator, Item, Folder } = customComps;
return items.map((item, i) => {
const id = `${item.type}_${i.toString()}`;
switch (item.type) {
case 'separator':
return Separator ? (
<Separator key={id} item={item} />
) : (
<SidebarSeparator key={id}>{item.name}</SidebarSeparator>
);
case 'folder':
return Folder ? (
<Folder key={id} item={item} level={level + 1} />
) : (
<PageTreeFolder key={id} item={item} level={level + 1}>
{renderSidebarList(item.children, level + 1, customComps)}
</PageTreeFolder>
);
default:
return Item ? (
<Item key={item.url} item={item} />
) : (
<SidebarItem
key={item.url}
href={item.url}
external={item.external}
icon={item.icon}
>
{item.name}
</SidebarItem>
);
}
});
}
/**
* Render sidebar items from page tree
*/
export function SidebarItems(props: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return useMemo(
() => renderSidebarList(root.children, 1, props.components ?? {}),
[root, props.components],
);
}
+24 -30
View File
@@ -10,7 +10,7 @@ import {
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useEffect,
useMemo,
useRef,
useState,
@@ -111,32 +111,25 @@ export function CollapsibleSidebar(props: SidebarProps) {
}, []);
return (
<>
<SidebarCollapseTrigger
className={cn(
'fixed bottom-3 start-2 z-20 transition-opacity max-md:hidden',
(!collapsed || hover) && 'opacity-0',
)}
/>
<Sidebar
{...props}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
className={cn(
'transition-[flex,margin,opacity,transform]',
collapsed &&
'md:-me-[var(--fd-sidebar-offset)] md:flex-initial md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-[var(--fd-sidebar-offset)]',
collapsed && hover && 'md:translate-x-0',
collapsed && !hover && 'md:z-10 md:opacity-0',
props.className,
)}
style={
{
'--fd-sidebar-offset': 'calc(var(--fd-sidebar-width) - 30px)',
} as object
}
/>
</>
<Sidebar
{...props}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
data-collapsed={collapsed}
className={cn(
'transition-[flex,margin,opacity,transform]',
collapsed &&
'md:-me-[var(--fd-sidebar-width)] md:flex-initial md:translate-x-[calc(var(--fd-sidebar-offset)*-1)] rtl:md:translate-x-[var(--fd-sidebar-offset)]',
collapsed && hover && 'z-50 md:translate-x-0',
collapsed && !hover && 'md:opacity-0',
props.className,
)}
style={
{
'--fd-sidebar-offset': 'calc(var(--fd-sidebar-width) - 20px)',
} as object
}
/>
);
}
@@ -175,7 +168,7 @@ export function Sidebar({
<div
{...inner}
className={cn(
'flex size-full flex-col pt-2 md:ms-auto md:w-[var(--fd-sidebar-width)] md:border-e md:pt-4',
'flex size-full max-w-full flex-col pt-2 md:ms-auto md:w-[var(--fd-sidebar-width)] md:border-e md:pt-4',
inner?.className,
)}
>
@@ -319,7 +312,7 @@ export function SidebarFolderLink(props: LinkProps) {
const active =
props.href !== undefined && isActive(props.href, pathname, false);
useLayoutEffect(() => {
useEffect(() => {
if (active) {
setOpen(true);
}
@@ -368,12 +361,13 @@ export function SidebarFolderContent(props: CollapsibleContentProps) {
export function SidebarCollapseTrigger(
props: ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { setCollapsed } = useSidebar();
const { collapsed, setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
data-collapsed={collapsed}
{...props}
className={cn(
buttonVariants({
+2 -1
View File
@@ -3,7 +3,7 @@ import type { LinkItemType } from '@/layouts/links';
import type { NavProviderProps, TitleProps } from '@/components/layout/nav';
import { Slot } from '@radix-ui/react-slot';
interface NavOptions extends SharedNavProps {
export interface NavOptions extends SharedNavProps {
enabled: boolean;
component: ReactNode;
}
@@ -61,6 +61,7 @@ export function getLinks(
type: 'icon',
url: githubUrl,
text: 'Github',
label: 'GitHub',
icon: (
<svg role="img" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
+51 -1
View File
@@ -1,6 +1,7 @@
'use client';
import {
Fragment,
type HTMLAttributes,
useContext,
useEffect,
@@ -12,11 +13,15 @@ import Link from 'next/link';
import { cva } from 'class-variance-authority';
import { cn } from '@/utils/cn';
import { useI18n } from './contexts/i18n';
import { useTreeContext } from './contexts/tree';
import { useTreeContext, useTreePath } from './contexts/tree';
import { useSidebar } from '@/contexts/sidebar';
import type { PageTree } from 'fumadocs-core/server';
import { usePathname } from 'next/navigation';
import { NavContext } from '@/components/layout/nav';
import {
type BreadcrumbOptions,
getBreadcrumbItemsFromPath,
} from 'fumadocs-core/breadcrumb';
export function PageContainer(props: HTMLAttributes<HTMLDivElement>) {
const { collapsed } = useSidebar();
@@ -174,3 +179,48 @@ export function Footer({ items }: FooterProps) {
</div>
);
}
export interface BreadcrumbProps extends BreadcrumbOptions {
/**
* Show the full path to the current page
*
* @defaultValue false
* @deprecated use `includePage` instead
*/
full?: boolean;
}
export function Breadcrumb(options: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage: options.includePage ?? false,
...options,
});
}, [options, path, root]);
if (items.length === 0) return null;
return (
<div className="-mb-3 flex flex-row items-center gap-1 text-sm font-medium text-fd-muted-foreground">
{items.map((item, i) => (
<Fragment key={i}>
{i !== 0 && (
<ChevronRight className="size-4 shrink-0 rtl:rotate-180" />
)}
{item.url ? (
<Link
href={item.url}
className="truncate hover:text-fd-accent-foreground"
>
{item.name}
</Link>
) : (
<span className="truncate">{item.name}</span>
)}
</Fragment>
))}
</div>
);
}
+3 -7
View File
@@ -5,7 +5,6 @@ import {
type HTMLAttributes,
type ReactNode,
} from 'react';
import dynamic from 'next/dynamic';
import type { LoaderConfig, LoaderOutput, Page } from 'fumadocs-core/source';
import { type AnchorProviderProps, AnchorProvider } from 'fumadocs-core/toc';
import { Card, Cards } from '@/components/card';
@@ -17,11 +16,9 @@ import {
LastUpdate,
PageContainer,
PageHeader,
} from './page.client';
import {
Breadcrumb,
type BreadcrumbProps,
} from '@/components/layout/breadcrumb';
} from './page.client';
import { Toc, TOCItems, type TOCProps } from '@/components/layout/toc';
import {
TocPopoverTrigger,
@@ -31,8 +28,7 @@ import {
import { buttonVariants } from '@/components/ui/button';
import { Edit, Text } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
const ClerkTOCItems = dynamic(() => import('@/components/layout/toc-clerk'));
import ClerkTOCItems from '@/components/layout/toc-clerk';
type TableOfContentOptions = Omit<TOCProps, 'items' | 'children'> &
Pick<AnchorProviderProps, 'single'> & {
@@ -175,7 +171,7 @@ export function DocsPage({
{replaceOrDefault(
{ enabled: tocEnabled, component: tocReplace },
<Toc>
<div className="flex h-full w-[var(--fd-toc-width)] max-w-full flex-col gap-3">
<div className="flex h-full w-[var(--fd-toc-width)] max-w-full flex-col gap-3 pe-2">
{tocOptions.header}
<h3 className="-ms-0.5 inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground">
<Text className="size-4" />
-13
View File
@@ -142,19 +142,6 @@ export const docsUi = plugin.withOptions<DocsUIOptions>(
'background-color': `theme('colors.fd-background')`,
color: `theme('colors.fd-foreground')`,
},
'@screen md': {
':root': {
'--fd-sidebar-width': '260px',
},
},
'@screen lg': {
':root': {
'--fd-toc-width': '240px',
},
},
'@screen xl': {
':root': { '--fd-toc-width': '260px' },
},
});
if (css) addComponents(css);