mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
UI: Support switching between page trees with RootToggle component
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
Add `id` to page trees
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support switching between page trees with `RootToggle` component
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@ const exportedComponents = [
|
||||
'dialog/search',
|
||||
'dialog/search-default',
|
||||
'dialog/search-algolia',
|
||||
'layout/root-toggle',
|
||||
];
|
||||
|
||||
const injectImports = {
|
||||
|
||||
Reference in New Issue
Block a user