UI: Support switching between page trees with RootToggle component

This commit is contained in:
fuma-nama
2024-06-06 21:22:23 +08:00
parent 17d1086fbf
commit d88dfa677d
15 changed files with 191 additions and 56 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Add `id` to page trees
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Support switching between page trees with `RootToggle` component
+12 -14
View File
@@ -1,24 +1,19 @@
'use client';
import Link from 'next/link';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { type Mode, modes } from '@/utils/modes';
import { useMode } from '@/app/layout.client';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import { useSidebar } from 'fumadocs-ui/provider';
export function SidebarBanner(): React.ReactElement {
const mode = useMode();
const [open, setOpen] = useState(false);
const currentMode = modes.find((item) => item.param === mode) ?? modes[0];
useEffect(() => {
setOpen(false);
}, [mode]);
const { rootId, setRootId } = useSidebar();
const currentMode = modes.find((item) => item.param === rootId) ?? modes[0];
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -28,18 +23,21 @@ export function SidebarBanner(): React.ReactElement {
</PopoverTrigger>
<PopoverContent className="p-1">
{modes.map((item) => (
<Link
<button
key={item.param}
href={`/docs/${item.param}`}
onClick={() => {
setRootId(item.param);
setOpen(false);
}}
className={cn(
'flex flex-row gap-2 rounded-lg p-2',
mode === item.param
'flex flex-row gap-2 rounded-lg p-2 w-full',
currentMode.param === item.param
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50',
)}
>
<Item mode={item} />
</Link>
</button>
))}
</PopoverContent>
</Popover>
+20 -2
View File
@@ -2,7 +2,8 @@ import { DocsLayout } from 'fumadocs-ui/layout';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
import { utils } from '@/utils/source';
import { SidebarBanner } from '@/app/docs/layout.client';
import { RootToggle } from 'fumadocs-ui/components/layout/root-toggle';
import { modes } from '@/utils/modes';
export default function Layout({
children,
@@ -14,7 +15,24 @@ export default function Layout({
{...baseOptions}
tree={utils.pageTree}
sidebar={{
banner: <SidebarBanner />,
banner: (
<RootToggle
options={modes.map((mode) => ({
id: mode.param,
icon: (
<mode.icon
className="size-9 shrink-0 rounded-md bg-gradient-to-t from-background/80 p-1.5"
style={{
backgroundColor: `hsl(var(--${mode.param}-color)/.3)`,
color: `hsl(var(--${mode.param}-color))`,
}}
/>
),
title: mode.name,
description: mode.description,
}))}
/>
),
}}
>
{children}
+1
View File
@@ -22,6 +22,7 @@ export interface Separator {
export interface Folder {
type: 'folder';
id: string;
name: string;
root?: boolean;
defaultOpen?: boolean;
@@ -173,6 +173,7 @@ function buildFolderNode(
return removeUndefined({
type: 'folder',
id: folder.file.flattenedPath,
name: metadata?.title ?? index?.name ?? pathToName(folder.file.name),
icon: ctx.resolveIcon?.(metadata?.icon),
root: metadata?.root,
@@ -0,0 +1,70 @@
'use client';
import { ChevronDown } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { type ReactNode, useState } from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useTreeContext } from '@/contexts/tree';
import { cn } from '@/utils/cn';
interface Option {
/**
* ID of the folder, usually equal to the relative pathname
*/
id: string;
icon: ReactNode;
title: ReactNode;
description: ReactNode;
}
export function RootToggle({
options,
}: {
options: Option[];
}): React.ReactElement {
const [open, setOpen] = useState(false);
const { root } = useTreeContext();
const { rootId = 'type' in root ? root.id : undefined, setRootId } =
useSidebar();
const selected = options.find((item) => item.id === rootId) ?? options[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="-mx-2 flex flex-row items-center gap-2 rounded-lg p-2 hover:bg-muted">
<Item {...selected} />
<ChevronDown className="size-4 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent className="p-1 w-[var(--radix-popover-trigger-width)]">
{options.map((item) => (
<button
key={item.id}
onClick={() => {
setRootId(item.id);
setOpen(false);
}}
className={cn(
'flex flex-row gap-2 rounded-lg p-2 w-full',
selected.id === item.id
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50',
)}
>
<Item {...item} />
</button>
))}
</PopoverContent>
</Popover>
);
}
function Item({ title, icon, description }: Option): React.ReactElement {
return (
<>
{icon}
<div className="flex-1 text-left">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</>
);
}
@@ -26,6 +26,7 @@ import {
CollapsibleTrigger,
} from '../ui/collapsible';
import { ThemeToggle } from './theme-toggle';
import { useSidebar } from '@/contexts/sidebar';
export interface SidebarProps {
items: LinkItemType[];
@@ -134,14 +135,17 @@ function ViewportContent({
}: {
children: React.ReactNode;
}): React.ReactElement {
const { root } = useTreeContext();
const { rootId } = useSidebar();
const { root, folders } = useTreeContext();
return (
<ScrollArea className="flex-1">
<ScrollViewport>
<div className="flex flex-col gap-8 p-4 pb-10 md:px-3">
{children}
<NodeList items={root.children} />
<NodeList
items={((rootId ? folders.get(rootId) : null) ?? root).children}
/>
</div>
</ScrollViewport>
</ScrollArea>
+1 -1
View File
@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
side="bottom"
className={cn(
'z-50 min-w-[260px] max-w-[90vw] rounded-md border bg-popover p-2 text-sm text-popover-foreground shadow-md outline-none data-[state=closed]:animate-popover-out data-[state=open]:animate-popover-in',
'z-50 min-w-[240px] max-w-[98vw] rounded-md border bg-popover p-2 text-sm text-popover-foreground shadow-md outline-none data-[state=closed]:animate-popover-out data-[state=open]:animate-popover-in',
className,
)}
{...props}
+17 -7
View File
@@ -1,18 +1,20 @@
import { createContext, useContext, useState, useMemo } from 'react';
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
import { SidebarProvider as BaseProvider } from 'fumadocs-core/sidebar';
import { usePathname } from 'next/navigation';
interface SidebarCollapseContext {
interface SidebarContext {
open: boolean;
setOpen: (v: boolean) => void;
collapsed: boolean;
setCollapsed: (v: boolean) => void;
rootId?: string;
setRootId: (root: string) => void;
}
const SidebarContext = createContext<SidebarCollapseContext | undefined>(
undefined,
);
const SidebarContext = createContext<SidebarContext | undefined>(undefined);
export function useSidebar(): SidebarCollapseContext {
export function useSidebar(): SidebarContext {
const ctx = useContext(SidebarContext);
if (!ctx) throw new Error('Missing root provider');
return ctx;
@@ -25,6 +27,12 @@ export function SidebarProvider({
}): React.ReactElement {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
const [rootId, setRootId] = useState<string>();
useEffect(() => {
setRootId(undefined);
}, [pathname]);
return (
<SidebarContext.Provider
@@ -34,8 +42,10 @@ export function SidebarProvider({
setOpen,
collapsed,
setCollapsed,
rootId,
setRootId,
}),
[open, collapsed],
[open, collapsed, rootId],
)}
>
<BaseProvider open={open} onOpenChange={setOpen}>
+19 -1
View File
@@ -6,6 +6,7 @@ import { hasActive } from '@/utils/shared';
interface TreeContextType {
tree: PageTree.Root;
navigation: PageTree.Item[];
folders: Map<string, PageTree.Folder>;
root: PageTree.Root | PageTree.Folder;
}
@@ -42,6 +43,18 @@ function getNavigationList(tree: PageTree.Node[]): PageTree.Item[] {
});
}
function scanFolders(
tree: PageTree.Node[],
map: Map<string, PageTree.Folder>,
): void {
tree.forEach((node) => {
if (node.type === 'folder') {
scanFolders(node.children, map);
map.set(node.id, node);
}
});
}
export function TreeContextProvider({
children,
tree,
@@ -51,11 +64,16 @@ export function TreeContextProvider({
}): React.ReactElement {
const pathname = usePathname();
const value = useMemo<TreeContextType>(() => {
const folders = new Map();
scanFolders(tree.children, folders);
const root = findRoot(tree.children, pathname) ?? tree;
const navigation = getNavigationList(root.children);
return {
root,
navigation: getNavigationList(root.children),
navigation,
folders,
tree,
};
}, [pathname, tree]);
+1
View File
@@ -11,6 +11,7 @@ import type { NavProps } from '@/components/layout/nav';
import { useSearchContext } from '@/contexts/search';
export { TreeContextProvider } from './contexts/tree';
export { SidebarProvider } from './contexts/sidebar';
export { Nav } from './components/layout/nav';
export { Sidebar } from './components/layout/sidebar';
export { DynamicSidebar } from './components/layout/dynamic-sidebar';
+31 -26
View File
@@ -12,6 +12,7 @@ declare const {
DynamicSidebar,
SubNav,
Sidebar,
SidebarProvider,
}: typeof import('./layout.client');
type ActiveType = 'none' | 'url' | 'nested-url';
@@ -123,7 +124,7 @@ export function DocsLayout({
<>
<Link
href={nav?.url ?? '/'}
className="mb-2 inline-flex items-center gap-3 text-base font-semibold max-md:hidden"
className="mb-2 inline-flex items-center gap-2.5 font-semibold max-md:hidden"
>
{nav?.title}
</Link>
@@ -133,32 +134,36 @@ export function DocsLayout({
return (
<TreeContextProvider tree={tree}>
{nav?.component ?? <SubNav {...nav} />}
<div
{...containerProps}
className={cn('flex flex-1 flex-row', containerProps?.className)}
>
{replaceOrDefault(
sidebar,
sidebarCollapsible ? (
<DynamicSidebar
items={finalLinks}
defaultOpenLevel={sidebar.defaultOpenLevel}
banner={banner}
footer={sidebar.footer}
/>
) : (
<Sidebar
items={finalLinks}
defaultOpenLevel={sidebar.defaultOpenLevel}
banner={banner}
footer={sidebar.footer}
/>
),
)}
<SidebarProvider>
{nav?.component ?? <SubNav {...nav} />}
<div
{...containerProps}
className={cn('flex flex-1 flex-row', containerProps?.className)}
>
{replaceOrDefault(
sidebar,
sidebarCollapsible ? (
<DynamicSidebar
items={finalLinks}
defaultOpenLevel={sidebar.defaultOpenLevel}
banner={banner}
footer={sidebar.footer}
components={sidebar.components}
/>
) : (
<Sidebar
items={finalLinks}
defaultOpenLevel={sidebar.defaultOpenLevel}
banner={banner}
footer={sidebar.footer}
components={sidebar.components}
/>
),
)}
{children}
</div>
{children}
</div>
</SidebarProvider>
</TreeContextProvider>
);
}
+1 -3
View File
@@ -67,9 +67,7 @@ export function RootProvider({
search,
}: RootProviderProps): React.ReactElement {
let body = (
<DirectionProvider dir={dir ?? 'ltr'}>
<SidebarProvider>{children}</SidebarProvider>
</DirectionProvider>
<DirectionProvider dir={dir ?? 'ltr'}>{children}</DirectionProvider>
);
if (search?.enabled !== false)
+1
View File
@@ -20,6 +20,7 @@ const exportedComponents = [
'dialog/search',
'dialog/search-default',
'dialog/search-algolia',
'layout/root-toggle',
];
const injectImports = {