UI: headless sidebar component

This commit is contained in:
Fuma Nama
2025-11-28 18:33:35 +08:00
parent 16239e347b
commit 9a398832b2
12 changed files with 1105 additions and 1152 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'fumadocs-ui': minor
---
**Move Sidebar context into docs layouts**
`fumadocs-ui/contexts/sidebar` is removed, you can still reference the context with:
```ts
import { useSidebar } from 'fumadocs-ui/components/sidebar/base';
```
Make sure you're only accessing it in `<DocsLayout />`.
+431
View File
@@ -0,0 +1,431 @@
'use client';
import { ChevronDown, ExternalLink } from 'lucide-react';
import {
type ComponentProps,
createContext,
type ReactNode,
type RefObject,
use,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Link, { type LinkProps } from 'fumadocs-core/link';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { isActive } from '@/utils/is-active';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { type ScrollAreaProps } from '@radix-ui/react-scroll-area';
import type {
CollapsibleContentProps,
CollapsibleTriggerProps,
} from '@radix-ui/react-collapsible';
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
import { Presence } from '@radix-ui/react-presence';
import scrollIntoView from 'scroll-into-view-if-needed';
import { usePathname } from 'fumadocs-core/framework';
interface SidebarContext {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
collapsed: boolean;
setCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
/**
* When set to false, don't close the sidebar when navigate to another page
*/
closeOnRedirect: RefObject<boolean>;
defaultOpenLevel: number;
prefetch: boolean;
mode: Mode;
}
export interface SidebarProviderProps {
/**
* Open folders by default if their level is lower or equal to a specific level
* (Starting from 1)
*
* @defaultValue 0
*/
defaultOpenLevel?: number;
/**
* Prefetch links
*
* @defaultValue true
*/
prefetch?: boolean;
children?: ReactNode;
}
type Mode = 'drawer' | 'full';
const SidebarContext = createContext<SidebarContext | null>(null);
const FolderContext = createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
depth: number;
} | null>(null);
export function SidebarProvider({
defaultOpenLevel = 0,
prefetch = true,
children,
}: SidebarProviderProps) {
const closeOnRedirect = useRef(true);
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
const mode: Mode = useMediaQuery('(width < 768px)') ? 'drawer' : 'full';
useOnChange(pathname, () => {
if (closeOnRedirect.current) {
setOpen(false);
}
closeOnRedirect.current = true;
});
return (
<SidebarContext
value={useMemo(
() => ({
open,
setOpen,
collapsed,
setCollapsed,
closeOnRedirect,
defaultOpenLevel,
prefetch,
mode,
}),
[open, collapsed, defaultOpenLevel, prefetch, mode],
)}
>
{children}
</SidebarContext>
);
}
export function useSidebar(): SidebarContext {
const ctx = use(SidebarContext);
if (!ctx)
throw new Error(
'Missing SidebarContext, make sure you have wrapped the component in <DocsLayout /> and the context is available.',
);
return ctx;
}
export function useFolderDepth() {
return use(FolderContext)?.depth ?? 0;
}
export function SidebarContent({
children,
aside,
}: {
aside?: (state: {
collapsed: boolean;
hovered: boolean;
}) => ComponentProps<'aside'>;
children: ReactNode;
}) {
const { collapsed, mode } = useSidebar();
const [hover, setHover] = useState(false);
const timerRef = useRef(0);
const ignoreHoverUntil = useRef(0);
useOnChange(collapsed, () => {
if (collapsed) {
setHover(true);
ignoreHoverUntil.current = Date.now() + 200;
setTimeout(() => {
setHover(false);
}, 200);
}
});
if (mode !== 'full') return;
return (
<aside
id="nd-sidebar"
data-collapsed={collapsed}
data-hovered={collapsed && hover}
{...aside?.({ collapsed, hovered: hover })}
onPointerEnter={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
setHover(true);
}}
onPointerLeave={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
setHover(false);
ignoreHoverUntil.current = Date.now() + 200;
},
Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100
? 0
: 500,
);
}}
>
{children}
</aside>
);
}
export function SidebarDrawerOverlay(props: ComponentProps<'div'>) {
const { open, setOpen, mode } = useSidebar();
if (mode !== 'drawer') return;
return (
<Presence present={open}>
<div
data-state={open ? 'open' : 'closed'}
onClick={() => setOpen(false)}
{...props}
/>
</Presence>
);
}
export function SidebarDrawerContent({
className,
children,
...props
}: ComponentProps<'aside'>) {
const { open, mode } = useSidebar();
const state = open ? 'open' : 'closed';
if (mode !== 'drawer') return;
return (
<Presence present={open}>
{({ present }) => (
<aside
id="nd-sidebar-mobile"
data-state={state}
className={cn(!present && 'invisible', className)}
{...props}
>
{children}
</aside>
)}
</Presence>
);
}
export function SidebarViewport(props: ScrollAreaProps) {
return (
<ScrollArea {...props} className={cn('min-h-0 flex-1', props.className)}>
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
{props.children}
</ScrollViewport>
</ScrollArea>
);
}
export function SidebarSeparator(props: ComponentProps<'p'>) {
return (
<p
{...props}
className={cn(
'inline-flex items-center gap-2 mb-1.5 px-2 mt-6 empty:mb-0',
props.className,
)}
>
{props.children}
</p>
);
}
export function SidebarItem({
icon,
children,
...props
}: LinkProps & {
icon?: ReactNode;
}) {
const pathname = usePathname();
const ref = useRef<HTMLAnchorElement>(null);
const { prefetch } = useSidebar();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
useAutoScroll(active, ref);
return (
<Link ref={ref} data-active={active} prefetch={prefetch} {...props}>
{icon ?? (props.external ? <ExternalLink /> : null)}
{children}
</Link>
);
}
export function SidebarFolder({
defaultOpen: defaultOpenOption = false,
...props
}: ComponentProps<'div'> & {
defaultOpen?: boolean | ((currentDefault: boolean) => boolean);
}) {
const { defaultOpenLevel } = useSidebar();
const depth = useFolderDepth() + 1;
const defaultOpen =
typeof defaultOpenOption === 'function'
? defaultOpenOption(defaultOpenLevel >= depth)
: defaultOpenLevel >= depth || defaultOpenOption;
const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => {
if (v) setOpen(v);
});
return (
<Collapsible open={open} onOpenChange={setOpen} {...props}>
<FolderContext
value={useMemo(() => ({ open, setOpen, depth }), [depth, open])}
>
{props.children}
</FolderContext>
</Collapsible>
);
}
export function SidebarFolderTrigger(props: CollapsibleTriggerProps) {
const { open } = use(FolderContext)!;
return (
<CollapsibleTrigger {...props}>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
}
export function SidebarFolderLink(props: LinkProps) {
const ref = useRef<HTMLAnchorElement>(null);
const { open, setOpen } = use(FolderContext)!;
const { prefetch } = useSidebar();
const pathname = usePathname();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
useAutoScroll(active, ref);
return (
<Link
ref={ref}
data-active={active}
onClick={(e) => {
if (
e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *')
) {
setOpen(!open);
e.preventDefault();
} else {
setOpen(active ? !open : true);
}
}}
prefetch={prefetch}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</Link>
);
}
export function SidebarFolderContent(props: CollapsibleContentProps) {
return <CollapsibleContent {...props}>{props.children}</CollapsibleContent>;
}
export function SidebarTrigger({
children,
...props
}: ComponentProps<'button'>) {
const { setOpen } = useSidebar();
return (
<button
aria-label="Open Sidebar"
onClick={() => setOpen((prev) => !prev)}
{...props}
>
{children}
</button>
);
}
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
const { collapsed, setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
data-collapsed={collapsed}
onClick={() => {
setCollapsed((prev) => !prev);
}}
{...props}
>
{props.children}
</button>
);
}
function useAutoScroll(
active: boolean,
ref: RefObject<HTMLAnchorElement | null>,
) {
const { mode } = useSidebar();
useEffect(() => {
if (active && ref.current) {
scrollIntoView(ref.current, {
behavior: 'smooth',
boundary: document.getElementById(
mode === 'drawer' ? 'nd-sidebar-mobile' : 'nd-sidebar',
),
scrollMode: 'if-needed',
});
}
}, [active, mode, ref]);
}
@@ -0,0 +1,116 @@
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { type FC, type ReactNode, useMemo, Fragment } from 'react';
import type * as PageTree from 'fumadocs-core/page-tree';
import {
SidebarSeparator,
SidebarItem,
SidebarFolder,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarFolderContent,
} from './base';
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
Separator: FC<{ item: PageTree.Separator }>;
}
/**
* Render sidebar items from page tree
*/
export function SidebarPageTree({
components = {},
}: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return useMemo(() => {
const { Separator, Item, Folder } = components;
function renderSidebarList(
items: PageTree.Node[],
level: number,
): ReactNode[] {
return items.map((item, i) => {
if (item.type === 'separator') {
if (Separator) return <Separator key={i} item={item} />;
return (
<SidebarSeparator key={i}>
{item.icon}
{item.name}
</SidebarSeparator>
);
}
if (item.type === 'folder') {
const children = renderSidebarList(item.children, level + 1);
if (Folder)
return (
<Folder key={i} item={item} level={level}>
{children}
</Folder>
);
return (
<PageTreeFolder key={i} item={item}>
{children}
</PageTreeFolder>
);
}
if (Item) return <Item key={item.url} item={item} />;
return (
<SidebarItem
key={item.url}
href={item.url}
external={item.external}
icon={item.icon}
>
{item.name}
</SidebarItem>
);
});
}
return (
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
);
}, [components, root]);
}
function PageTreeFolder({
item,
...props
}: {
item: PageTree.Folder;
children: ReactNode;
}) {
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={(value) =>
(item.defaultOpen ?? value) || path.includes(item)
}
>
{item.index ? (
<SidebarFolderLink
href={item.index.url}
external={item.index.external}
{...props}
>
{item.icon}
{item.name}
</SidebarFolderLink>
) : (
<SidebarFolderTrigger {...props}>
{item.icon}
{item.name}
</SidebarFolderTrigger>
)}
<SidebarFolderContent>{props.children}</SidebarFolderContent>
</SidebarFolder>
);
}
-71
View File
@@ -1,71 +0,0 @@
'use client';
import {
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
createContext,
use,
} from 'react';
import { usePathname } from 'fumadocs-core/framework';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
interface SidebarContext {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
collapsed: boolean;
setCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
/**
* When set to false, don't close the sidebar when navigate to another page
*/
closeOnRedirect: RefObject<boolean>;
}
const SidebarContext = createContext<SidebarContext | null>(null);
export function useSidebar(): SidebarContext {
const ctx = use(SidebarContext);
if (!ctx)
throw new Error(
'Missing SidebarContext, make sure you have wrapped the component in <RootProvider /> and the context is available.',
);
return ctx;
}
export function SidebarProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
const closeOnRedirect = useRef(true);
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
useOnChange(pathname, () => {
if (closeOnRedirect.current) {
setOpen(false);
}
closeOnRedirect.current = true;
});
return (
<SidebarContext
value={useMemo(
() => ({
open,
setOpen,
collapsed,
setCollapsed,
closeOnRedirect,
}),
[open, collapsed],
)}
>
{children}
</SidebarContext>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { useSidebar } from '@/contexts/sidebar';
import { useSidebar } from '@/components/sidebar/base';
import { SidebarCollapseTrigger } from './sidebar';
import { SearchToggle } from '../shared/search-toggle';
import { usePathname } from 'fumadocs-core/framework';
+151 -165
View File
@@ -13,10 +13,9 @@ import {
SidebarCollapseTrigger,
type SidebarComponents,
SidebarContent,
SidebarContentMobile,
SidebarDrawer,
SidebarLinkItem,
SidebarPageTree,
type SidebarProps,
SidebarTrigger,
SidebarViewport,
} from './sidebar';
@@ -64,7 +63,7 @@ export interface DocsLayoutProps extends BaseLayoutProps {
interface SidebarOptions
extends ComponentProps<'aside'>,
Pick<SidebarProps, 'defaultOpenLevel' | 'prefetch'> {
Pick<ComponentProps<typeof Sidebar>, 'defaultOpenLevel' | 'prefetch'> {
enabled?: boolean;
component?: ReactNode;
components?: Partial<SidebarComponents>;
@@ -90,6 +89,8 @@ export function DocsLayout({
sidebar: {
tabs: sidebarTabs,
enabled: sidebarEnabled = true,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
searchToggle = {},
@@ -121,14 +122,11 @@ export function DocsLayout({
collapsible = true,
component,
components,
defaultOpenLevel,
prefetch,
...rest
} = sidebarProps;
if (component) return component;
const iconLinks = links.filter((item) => item.type === 'icon');
const viewport = (
<SidebarViewport>
{links
@@ -144,192 +142,180 @@ export function DocsLayout({
</SidebarViewport>
);
const mobile = (
<SidebarContentMobile {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex text-fd-muted-foreground items-center gap-1.5">
<div className="flex flex-1">
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
return (
<>
{collapsible && <CollapsibleControl />}
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex">
<Link
href={nav.url ?? '/'}
className="inline-flex text-[0.9375rem] items-center gap-2.5 font-medium me-auto"
>
{nav.title}
</Link>
{nav.children}
{collapsible && (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'p-2',
size: 'icon-sm',
className: 'mb-auto text-fd-muted-foreground',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</div>
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
<LanguageToggleText />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
))}
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
<SidebarIcon />
</SidebarCollapseTrigger>
)}
>
<SidebarIcon />
</SidebarTrigger>
</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.lg ?? (
<LargeSearchToggle hideIfDisabled />
))}
{tabs.length > 0 && tabMode === 'auto' && (
<SidebarTabTrigger options={tabs} />
)}
{banner}
</div>
{tabs.length > 0 && <SidebarTabTrigger options={tabs} />}
{banner}
</div>
{viewport}
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
{footer}
</div>
</SidebarContentMobile>
);
const content = (
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex">
<Link
href={nav.url ?? '/'}
className="inline-flex text-[0.9375rem] items-center gap-2.5 font-medium me-auto"
>
{nav.title}
</Link>
{nav.children}
{collapsible && (
<SidebarCollapseTrigger
{viewport}
{(i18n ||
iconLinks.length > 0 ||
themeSwitch?.enabled !== false ||
footer) && (
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
<div className="flex text-fd-muted-foreground items-center empty:hidden">
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
</LanguageToggle>
)}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle
className="ms-auto p-0"
mode={themeSwitch.mode}
/>
))}
</div>
{footer}
</div>
)}
</SidebarContent>
<SidebarDrawer>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex text-fd-muted-foreground items-center gap-1.5">
<div className="flex flex-1">
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'p-2',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</div>
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
<LanguageToggleText />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle className="p-0" mode={themeSwitch.mode} />
))}
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'mb-auto text-fd-muted-foreground',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.lg ?? (
<LargeSearchToggle hideIfDisabled />
))}
{tabs.length > 0 && tabMode === 'auto' && (
<SidebarTabTrigger options={tabs} />
)}
{banner}
</div>
{viewport}
{(i18n ||
iconLinks.length > 0 ||
themeSwitch?.enabled !== false ||
footer) && (
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
<div className="flex text-fd-muted-foreground items-center empty:hidden">
{i18n && (
<LanguageToggle>
<Languages className="size-4.5" />
</LanguageToggle>
)}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle
className="ms-auto p-0"
mode={themeSwitch.mode}
/>
))}
</SidebarTrigger>
</div>
{tabs.length > 0 && <SidebarTabTrigger options={tabs} />}
{banner}
</div>
{viewport}
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
{footer}
</div>
)}
</SidebarContent>
);
return (
<Sidebar
defaultOpenLevel={defaultOpenLevel}
prefetch={prefetch}
full={
<>
{collapsible && <CollapsibleControl />}
{content}
</>
}
drawer={mobile}
/>
</SidebarDrawer>
</>
);
}
return (
<TreeContextProvider tree={tree}>
<LayoutContextProvider navTransparentMode={transparentMode}>
<LayoutBody {...props.containerProps}>
{nav.enabled !== false &&
(nav.component ?? (
<LayoutHeader
id="nd-subnav"
className="[grid-area:header] sticky top-(--fd-docs-row-1) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-14 md:hidden data-[transparent=false]:bg-fd-background/80"
>
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<LayoutBody {...props.containerProps}>
{nav.enabled !== false &&
(nav.component ?? (
<LayoutHeader
id="nd-subnav"
className="[grid-area:header] sticky top-(--fd-docs-row-1) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-14 md:hidden data-[transparent=false]:bg-fd-background/80"
>
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
{sidebarEnabled && (
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-semibold"
>
<SidebarIcon />
</SidebarTrigger>
)}
</LayoutHeader>
))}
{sidebarEnabled && sidebar()}
{tabMode === 'top' && tabs.length > 0 && (
<LayoutTabs
options={tabs}
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
/>
)}
{children}
</LayoutBody>
{nav.title}
</Link>
<div className="flex-1">{nav.children}</div>
{searchToggle.enabled !== false &&
(searchToggle.components?.sm ?? (
<SearchToggle className="p-2" hideIfDisabled />
))}
{sidebarEnabled && (
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarTrigger>
)}
</LayoutHeader>
))}
{sidebarEnabled && sidebar()}
{tabMode === 'top' && tabs.length > 0 && (
<LayoutTabs
options={tabs}
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
/>
)}
{children}
</LayoutBody>
</Sidebar>
</LayoutContextProvider>
</TreeContextProvider>
);
+140 -395
View File
@@ -1,445 +1,199 @@
'use client';
import { ChevronDown, ExternalLink } from 'lucide-react';
import { usePathname } from 'fumadocs-core/framework';
import {
type ComponentProps,
createContext,
type FC,
Fragment,
type ReactNode,
use,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Link, { type LinkProps } from 'fumadocs-core/link';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { isActive } from '@/utils/is-active';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { type ScrollAreaProps } from '@radix-ui/react-scroll-area';
import { useSidebar } from '@/contexts/sidebar';
import { cva } from 'class-variance-authority';
import type {
CollapsibleContentProps,
CollapsibleTriggerProps,
} from '@radix-ui/react-collapsible';
import * as Base from '@/components/sidebar/base';
import type * as PageTree from 'fumadocs-core/page-tree';
import { cn } from '@/utils/cn';
import {
Fragment,
type HTMLAttributes,
useMemo,
type ComponentProps,
type FC,
type ReactNode,
} from 'react';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
import { Presence } from '@radix-ui/react-presence';
import type { LinkItemType } from '@/layouts/shared/link-item';
import scrollIntoView from 'scroll-into-view-if-needed';
type Mode = 'drawer' | 'full';
export type SidebarProps = {
/**
* Open folders by default if their level is lower or equal to a specific level
* (Starting from 1)
*
* @defaultValue 0
*/
defaultOpenLevel?: number;
/**
* Prefetch links
*
* @defaultValue true
*/
prefetch?: boolean;
} & Record<Mode, ReactNode>;
interface InternalContext {
defaultOpenLevel: number;
prefetch: boolean;
level: number;
mode: Mode;
}
import type { LinkItemType } from '../shared/link-item';
import { cva } from 'class-variance-authority';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0',
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
active: {
true: 'bg-fd-primary/10 text-fd-primary',
false:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
variant: {
link: 'data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
},
},
},
);
const Context = createContext<InternalContext | null>(null);
const FolderContext = createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
} | null>(null);
export function Sidebar({
defaultOpenLevel = 0,
prefetch = true,
...renderers
}: SidebarProps) {
const mode: Mode = useMediaQuery('(width < 768px)') ? 'drawer' : 'full';
const context = useMemo<InternalContext>(() => {
return {
defaultOpenLevel,
prefetch,
level: 1,
mode,
};
}, [defaultOpenLevel, mode, prefetch]);
return <Context value={context}>{renderers[mode]}</Context>;
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
export function SidebarContent(props: ComponentProps<'aside'>) {
const { collapsed } = useSidebar();
const [hover, setHover] = useState(false);
const timerRef = useRef(0);
const ignoreHoverUntil = useRef(0);
export const {
SidebarProvider: Sidebar,
SidebarFolder,
SidebarCollapseTrigger,
SidebarViewport,
SidebarTrigger,
} = Base;
useOnChange(collapsed, () => {
if (collapsed) {
setHover(true);
ignoreHoverUntil.current = Date.now() + 200;
setTimeout(() => {
setHover(false);
}, 200);
}
});
return (
<>
<aside
id="nd-sidebar"
{...props}
data-collapsed={collapsed}
className={cn(
'sticky top-(--fd-docs-row-1) [grid-area:sidebar] h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col items-end z-20 bg-fd-card text-sm border-e *:w-(--fd-sidebar-width) max-md:hidden',
collapsed && [
'fixed start-0 inset-y-2 h-auto rounded-xl border transition-[opacity,translate] duration-200',
hover
? 'z-50 shadow-lg translate-x-2 rtl:-translate-x-2'
: 'opacity-0 -translate-x-[calc(100%-16px)] rtl:translate-x-[calc(100%-16px)]',
],
props.className,
)}
onPointerEnter={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
setHover(true);
}}
onPointerLeave={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
setHover(false);
ignoreHoverUntil.current = Date.now() + 200;
},
Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100
? 0
: 500,
);
}}
>
{props.children}
</aside>
</>
);
}
export function SidebarContentMobile({
export function SidebarContent({
className,
children,
...props
}: ComponentProps<'aside'>) {
const { open, setOpen } = useSidebar();
const state = open ? 'open' : 'closed';
return (
<Base.SidebarContent
aside={({ collapsed, hovered }) => ({
className: cn(
'sticky top-(--fd-docs-row-1) [grid-area:sidebar] h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col items-end z-20 bg-fd-card text-sm border-e *:w-(--fd-sidebar-width) max-md:hidden',
collapsed && [
'fixed start-0 inset-y-2 h-auto rounded-xl border transition-[opacity,translate] duration-200',
hovered
? 'z-50 shadow-lg translate-x-2 rtl:-translate-x-2'
: 'opacity-0 -translate-x-[calc(100%-16px)] rtl:translate-x-[calc(100%-16px)]',
],
className,
),
...props,
})}
>
{children}
</Base.SidebarContent>
);
}
export function SidebarDrawer({
children,
className,
...props
}: ComponentProps<typeof Base.SidebarDrawerContent>) {
return (
<>
<Presence present={open}>
<div
data-state={state}
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
{({ present }) => (
<aside
id="nd-sidebar-mobile"
{...props}
data-state={state}
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
!present && 'invisible',
className,
)}
>
{children}
</aside>
<Base.SidebarDrawerOverlay className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
<Base.SidebarDrawerContent
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
className,
)}
</Presence>
{...props}
>
{children}
</Base.SidebarDrawerContent>
</>
);
}
export function SidebarViewport(props: ScrollAreaProps) {
return (
<ScrollArea {...props} className={cn('min-h-0 flex-1', props.className)}>
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
'--sidebar-item-offset': 'calc(var(--spacing) * 2)',
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
{props.children}
</ScrollViewport>
</ScrollArea>
);
}
export function SidebarSeparator({
className,
style,
children,
...props
}: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
export function SidebarSeparator(props: ComponentProps<'p'>) {
return (
<p
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
className={cn(
'inline-flex items-center gap-2 mb-1.5 px-2 ps-(--sidebar-item-offset) empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
props.className,
)}
>
{props.children}
</p>
{children}
</Base.SidebarSeparator>
);
}
export function SidebarItem({
icon,
className,
style,
children,
...props
}: LinkProps & {
icon?: ReactNode;
}) {
const pathname = usePathname();
const ref = useRef<HTMLAnchorElement>(null);
const active =
props.href !== undefined && isActive(props.href, pathname, false);
const { prefetch, mode } = useInternalContext();
useEffect(() => {
if (active && ref.current) {
scrollIntoView(ref.current, {
behavior: 'smooth',
boundary: document.getElementById(
mode === 'drawer' ? 'nd-sidebar-mobile' : 'nd-sidebar',
),
scrollMode: 'if-needed',
});
}
}, [active, mode]);
}: ComponentProps<typeof Base.SidebarItem>) {
const depth = Base.useFolderDepth();
return (
<Link
ref={ref}
<Base.SidebarItem
className={cn(itemVariants({ variant: 'link' }), className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
data-active={active}
className={cn(itemVariants({ active }), props.className)}
prefetch={prefetch}
>
{icon ?? (props.external ? <ExternalLink /> : null)}
{props.children}
</Link>
);
}
export function SidebarFolder({
defaultOpen = false,
...props
}: ComponentProps<'div'> & {
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => {
if (v) setOpen(v);
});
return (
<Collapsible open={open} onOpenChange={setOpen} {...props}>
<FolderContext.Provider
value={useMemo(() => ({ open, setOpen }), [open])}
>
{props.children}
</FolderContext.Provider>
</Collapsible>
{children}
</Base.SidebarItem>
);
}
export function SidebarFolderTrigger({
className,
style,
...props
}: CollapsibleTriggerProps) {
const { open } = useFolderContext();
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth();
return (
<CollapsibleTrigger
className={cn(itemVariants({ active: false }), 'w-full', className)}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
}
export function SidebarFolderLink(props: LinkProps) {
const { open, setOpen } = useFolderContext();
const { prefetch } = useInternalContext();
const pathname = usePathname();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
return (
<Link
{...props}
data-active={active}
className={cn(itemVariants({ active }), 'w-full', props.className)}
onClick={(e) => {
if (
e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *')
) {
setOpen(!open);
e.preventDefault();
} else {
setOpen(active ? !open : true);
}
<Base.SidebarFolderTrigger
className={cn(itemVariants(), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
prefetch={prefetch}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</Link>
</Base.SidebarFolderTrigger>
);
}
export function SidebarFolderContent(props: CollapsibleContentProps) {
const { level, ...ctx } = useInternalContext();
export function SidebarFolderLink({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderLink>) {
const depth = Base.useFolderDepth();
return (
<CollapsibleContent
<Base.SidebarFolderLink
className={cn(itemVariants({ variant: 'link' }), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderLink>
);
}
export function SidebarFolderContent({
className,
children,
...props
}: ComponentProps<typeof Base.SidebarFolderContent>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderContent
className={cn(
'relative',
level === 1 && [
depth === 1 && [
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
"**:data-[active=true]:before:content-[''] **:data-[active=true]:before:bg-fd-primary **:data-[active=true]:before:absolute **:data-[active=true]:before:w-px **:data-[active=true]:before:inset-y-2.5 **:data-[active=true]:before:start-2.5",
],
props.className,
className,
)}
style={
{
'--sidebar-item-offset': `calc(var(--spacing) * ${(level + 1) * 3})`,
...props.style,
} as object
}
>
<Context.Provider
value={useMemo(
() => ({
...ctx,
level: level + 1,
}),
[ctx, level],
)}
>
{props.children}
</Context.Provider>
</CollapsibleContent>
);
}
export function SidebarTrigger({
children,
...props
}: ComponentProps<'button'>) {
const { setOpen } = useSidebar();
return (
<button
{...props}
aria-label="Open Sidebar"
onClick={() => setOpen((prev) => !prev)}
>
{children}
</button>
</Base.SidebarFolderContent>
);
}
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
const { collapsed, setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
data-collapsed={collapsed}
{...props}
onClick={() => {
setCollapsed((prev) => !prev);
}}
>
{props.children}
</button>
);
}
function useFolderContext() {
const ctx = use(FolderContext);
if (!ctx) throw new Error('Missing sidebar folder');
return ctx;
}
function useInternalContext() {
const ctx = use(Context);
if (!ctx) throw new Error('<Sidebar /> component required.');
return ctx;
}
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
@@ -449,13 +203,14 @@ export interface SidebarComponents {
/**
* Render sidebar items from page tree
*/
export function SidebarPageTree(props: {
export function SidebarPageTree({
components,
}: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return useMemo(() => {
const { Separator, Item, Folder } = props.components ?? {};
const { Separator, Item, Folder = PageTreeFolder } = components ?? {};
function renderSidebarList(
items: PageTree.Node[],
@@ -473,18 +228,10 @@ export function SidebarPageTree(props: {
}
if (item.type === 'folder') {
const children = renderSidebarList(item.children, level + 1);
if (Folder)
return (
<Folder key={i} item={item} level={level}>
{children}
</Folder>
);
return (
<PageTreeFolder key={i} item={item}>
{children}
</PageTreeFolder>
<Folder key={i} item={item} level={level}>
{renderSidebarList(item.children, level + 1)}
</Folder>
);
}
@@ -505,7 +252,7 @@ export function SidebarPageTree(props: {
return (
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
);
}, [props.components, root]);
}, [components, root]);
}
function PageTreeFolder({
@@ -515,13 +262,12 @@ function PageTreeFolder({
item: PageTree.Folder;
children: ReactNode;
}) {
const { defaultOpenLevel, level } = useInternalContext();
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={
(item.defaultOpen ?? defaultOpenLevel >= level) || path.includes(item)
defaultOpen={(value) =>
(item.defaultOpen ?? value) || path.includes(item)
}
>
{item.index ? (
@@ -547,10 +293,11 @@ function PageTreeFolder({
export function SidebarLinkItem({
item,
...props
}: {
}: HTMLAttributes<HTMLElement> & {
item: Exclude<LinkItemType, { type: 'icon' }>;
className?: string;
}) {
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
if (item.type === 'menu')
return (
<SidebarFolder {...props}>
@@ -573,8 +320,6 @@ export function SidebarLinkItem({
</SidebarFolder>
);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
return (
<SidebarItem
href={item.url}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
use,
useMemo,
} from 'react';
import { useSidebar } from '@/contexts/sidebar';
import { useSidebar } from '@/components/sidebar/base';
import { ChevronDown } from 'lucide-react';
import Link from 'fumadocs-core/link';
import { usePathname } from 'fumadocs-core/framework';
+115 -121
View File
@@ -11,10 +11,9 @@ import {
SidebarCollapseTrigger,
type SidebarComponents,
SidebarContent,
SidebarContentMobile,
SidebarDrawer,
SidebarLinkItem,
SidebarPageTree,
type SidebarProps,
SidebarTrigger,
SidebarViewport,
} from './sidebar';
@@ -62,7 +61,7 @@ export interface DocsLayoutProps extends BaseLayoutProps {
interface SidebarOptions
extends ComponentProps<'aside'>,
Pick<SidebarProps, 'defaultOpenLevel' | 'prefetch'> {
Pick<ComponentProps<typeof Sidebar>, 'defaultOpenLevel' | 'prefetch'> {
components?: Partial<SidebarComponents>;
/**
@@ -85,7 +84,12 @@ export function DocsLayout(props: DocsLayoutProps) {
const {
tabMode = 'sidebar',
nav = {},
sidebar: { tabs: tabOptions, ...sidebarProps } = {},
sidebar: {
tabs: tabOptions,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
i18n = false,
themeSwitch = {},
} = props;
@@ -114,8 +118,6 @@ export function DocsLayout(props: DocsLayoutProps) {
footer,
components,
collapsible = true,
prefetch,
defaultOpenLevel,
...rest
} = sidebarProps;
const Header =
@@ -159,128 +161,118 @@ export function DocsLayout(props: DocsLayoutProps) {
</SidebarViewport>
);
const content = (
<SidebarContent {...rest}>
<Header className="empty:hidden">
{navMode === 'auto' && (
<div className="flex justify-between">
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-medium"
>
{nav.title}
</Link>
{collapsible && (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'mt-px mb-auto text-fd-muted-foreground',
}),
)}
return (
<>
<SidebarContent {...rest}>
<Header className="empty:hidden">
{navMode === 'auto' && (
<div className="flex justify-between">
<Link
href={nav.url ?? '/'}
className="inline-flex items-center gap-2.5 font-medium"
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
</div>
)}
{nav.children}
{tabs.length > 0 && (
<SidebarTabTrigger
options={tabs}
className={cn(tabMode === 'navbar' && 'lg:hidden')}
/>
)}
</Header>
{viewport}
<Footer
className={cn(
'hidden flex-row text-fd-muted-foreground items-center',
iconLinks.length > 0 && 'max-lg:flex',
)}
>
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'lg:hidden',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</Footer>
</SidebarContent>
);
const mobile = (
<SidebarContentMobile {...rest}>
<Header>
<SidebarTrigger
{nav.title}
</Link>
{collapsible && (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'mt-px mb-auto text-fd-muted-foreground',
}),
)}
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
</div>
)}
{nav.children}
{tabs.length > 0 && (
<SidebarTabTrigger
options={tabs}
className={cn(tabMode === 'navbar' && 'lg:hidden')}
/>
)}
</Header>
{viewport}
<Footer
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'ms-auto text-fd-muted-foreground',
}),
'hidden flex-row text-fd-muted-foreground items-center',
iconLinks.length > 0 && 'max-lg:flex',
)}
>
<X />
</SidebarTrigger>
{tabs.length > 0 && <SidebarTabTrigger options={tabs} />}
</Header>
{viewport}
<Footer
className={cn(
'hidden flex-row items-center justify-end',
(i18n || themeSwitch.enabled !== false) && 'flex',
iconLinks.length > 0 && 'max-lg:flex',
)}
>
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'lg:hidden',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</Footer>
</SidebarContent>
<SidebarDrawer {...rest}>
<Header>
<SidebarTrigger
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'ms-auto text-fd-muted-foreground',
}),
'text-fd-muted-foreground lg:hidden',
i === iconLinks.length - 1 && 'me-auto',
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
{i18n && (
<LanguageToggle>
<Languages className="size-4.5 text-fd-muted-foreground" />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle mode={themeSwitch.mode ?? 'light-dark-system'} />
<X />
</SidebarTrigger>
{tabs.length > 0 && <SidebarTabTrigger options={tabs} />}
</Header>
{viewport}
<Footer
className={cn(
'hidden flex-row items-center justify-end',
(i18n || themeSwitch.enabled !== false) && 'flex',
iconLinks.length > 0 && 'max-lg:flex',
)}
>
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
}),
'text-fd-muted-foreground lg:hidden',
i === iconLinks.length - 1 && 'me-auto',
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</Footer>
</SidebarContentMobile>
);
return (
<Sidebar
defaultOpenLevel={defaultOpenLevel}
prefetch={prefetch}
full={content}
drawer={mobile}
/>
{i18n && (
<LanguageToggle>
<Languages className="size-4.5 text-fd-muted-foreground" />
</LanguageToggle>
)}
{themeSwitch.enabled !== false &&
(themeSwitch.component ?? (
<ThemeToggle mode={themeSwitch.mode ?? 'light-dark-system'} />
))}
</Footer>
</SidebarDrawer>
</>
);
}
@@ -291,11 +283,13 @@ export function DocsLayout(props: DocsLayoutProps) {
tabMode={tabMode}
navTransparentMode={nav.transparentMode}
>
<LayoutBody {...props.containerProps}>
{sidebar()}
<DocsNavbar {...props} links={links} tabs={tabs} />
{props.children}
</LayoutBody>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<LayoutBody {...props.containerProps}>
{sidebar()}
<DocsNavbar {...props} links={links} tabs={tabs} />
{props.children}
</LayoutBody>
</Sidebar>
</LayoutContextProvider>
</TreeContextProvider>
);
+135 -391
View File
@@ -1,450 +1,203 @@
'use client';
import { ChevronDown, ExternalLink } from 'lucide-react';
import { usePathname } from 'fumadocs-core/framework';
import * as Base from '@/components/sidebar/base';
import type * as PageTree from 'fumadocs-core/page-tree';
import { cn } from '@/utils/cn';
import {
type ComponentProps,
createContext,
type FC,
Fragment,
type HTMLAttributes,
useMemo,
type ComponentProps,
type FC,
type ReactNode,
use,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Link, { type LinkProps } from 'fumadocs-core/link';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { isActive } from '@/utils/is-active';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { type ScrollAreaProps } from '@radix-ui/react-scroll-area';
import { useSidebar } from '@/contexts/sidebar';
import { cva } from 'class-variance-authority';
import type {
CollapsibleContentProps,
CollapsibleTriggerProps,
} from '@radix-ui/react-collapsible';
import type * as PageTree from 'fumadocs-core/page-tree';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { useMediaQuery } from 'fumadocs-core/utils/use-media-query';
import { Presence } from '@radix-ui/react-presence';
import type { LinkItemType } from '@/layouts/shared/link-item';
import scrollIntoView from 'scroll-into-view-if-needed';
import type { LinkItemType } from '../shared/link-item';
import { cva } from 'class-variance-authority';
import { LayoutContext } from './client';
type Mode = 'drawer' | 'full';
const itemVariants = cva([
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
'data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary',
'data-[active=false]:transition-colors data-[active=false]:hover:bg-fd-accent/50 data-[active=false]:hover:text-fd-accent-foreground/80 data-[active=false]:hover:transition-none',
]);
export type SidebarProps = {
/**
* Open folders by default if their level is lower or equal to a specific level
* (Starting from 1)
*
* @defaultValue 0
*/
defaultOpenLevel?: number;
/**
* Prefetch links
*
* @defaultValue true
*/
prefetch?: boolean;
} & Record<Mode, ReactNode>;
interface InternalContext {
defaultOpenLevel: number;
prefetch: boolean;
level: number;
mode: Mode;
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 ps-(--sidebar-item-offset) text-start text-fd-muted-foreground [overflow-wrap:anywhere] [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
active: {
true: 'bg-fd-primary/10 text-fd-primary',
false:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
},
},
);
export const {
SidebarProvider: Sidebar,
SidebarFolder,
SidebarCollapseTrigger,
SidebarViewport,
SidebarTrigger,
} = Base;
const Context = createContext<InternalContext | null>(null);
const FolderContext = createContext<{
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
} | null>(null);
export function Sidebar({
defaultOpenLevel = 0,
prefetch = true,
...renderers
}: SidebarProps) {
const mode: Mode = useMediaQuery('(width < 768px)') ? 'drawer' : 'full';
const context = useMemo<InternalContext>(() => {
return {
defaultOpenLevel,
prefetch,
level: 1,
mode,
};
}, [defaultOpenLevel, mode, prefetch]);
return <Context value={context}>{renderers[mode]}</Context>;
}
export function SidebarContent(props: ComponentProps<'aside'>) {
const { collapsed } = useSidebar();
const [hover, setHover] = useState(false);
const timerRef = useRef(0);
const ignoreHoverUntil = useRef(0);
export function SidebarContent({
className,
children,
...props
}: ComponentProps<'aside'>) {
const { navMode } = use(LayoutContext)!;
useOnChange(collapsed, () => {
if (collapsed) {
setHover(true);
ignoreHoverUntil.current = Date.now() + 200;
setTimeout(() => {
setHover(false);
}, 200);
}
});
return (
<>
<aside
id="nd-sidebar"
{...props}
data-collapsed={collapsed}
className={cn(
<Base.SidebarContent
aside={({ collapsed, hovered }) => ({
className: cn(
'sticky [grid-area:sidebar] flex flex-col items-end z-20 text-sm *:w-(--fd-sidebar-width) max-md:hidden',
(navMode === 'auto' || collapsed) && 'bg-fd-card border-e',
(navMode === 'auto' || collapsed) &&
'data-[collapsed=true]:bg-fd-card data-[collapsed=true]:border-e',
navMode === 'auto'
? 'top-(--fd-docs-row-1) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))]'
: 'top-(--fd-docs-row-2) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-2))]',
collapsed && [
'fixed start-0 inset-y-2 h-auto rounded-xl border transition-[opacity,translate] duration-200',
hover
hovered
? 'z-50 shadow-lg translate-x-2 rtl:-translate-x-2'
: 'opacity-0 -translate-x-[calc(100%-16px)] rtl:translate-x-[calc(100%-16px)]',
],
props.className,
)}
onPointerEnter={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
setHover(true);
}}
onPointerLeave={(e) => {
if (
!collapsed ||
e.pointerType === 'touch' ||
ignoreHoverUntil.current > Date.now()
)
return;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(
() => {
setHover(false);
ignoreHoverUntil.current = Date.now() + 200;
},
Math.min(e.clientX, document.body.clientWidth - e.clientX) > 100
? 0
: 500,
);
}}
>
{props.children}
</aside>
</>
className,
),
})}
{...props}
>
{children}
</Base.SidebarContent>
);
}
export function SidebarContentMobile({
className,
export function SidebarDrawer({
children,
className,
...props
}: ComponentProps<'aside'>) {
const { open, setOpen } = useSidebar();
const state = open ? 'open' : 'closed';
}: ComponentProps<typeof Base.SidebarDrawerContent>) {
return (
<>
<Presence present={open}>
<div
data-state={state}
className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out"
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
{({ present }) => (
<aside
id="nd-sidebar-mobile"
{...props}
data-state={state}
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
!present && 'invisible',
className,
)}
>
{children}
</aside>
<Base.SidebarDrawerOverlay className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
<Base.SidebarDrawerContent
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
className,
)}
</Presence>
{...props}
>
{children}
</Base.SidebarDrawerContent>
</>
);
}
export function SidebarViewport(props: ScrollAreaProps) {
return (
<ScrollArea {...props} className={cn('min-h-0 flex-1', props.className)}>
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
'--sidebar-item-offset': 'calc(var(--spacing) * 2)',
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
{props.children}
</ScrollViewport>
</ScrollArea>
);
}
export function SidebarSeparator({
className,
style,
children,
...props
}: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
export function SidebarSeparator(props: ComponentProps<'p'>) {
return (
<p
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
className={cn(
'inline-flex items-center gap-2 mb-1.5 px-2 ps-(--sidebar-item-offset) empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
props.className,
)}
>
{props.children}
</p>
{children}
</Base.SidebarSeparator>
);
}
export function SidebarItem({
icon,
className,
style,
children,
...props
}: LinkProps & {
icon?: ReactNode;
}) {
const pathname = usePathname();
const ref = useRef<HTMLAnchorElement>(null);
const active =
props.href !== undefined && isActive(props.href, pathname, false);
const { prefetch, mode } = useInternalContext();
useEffect(() => {
if (active && ref.current) {
scrollIntoView(ref.current, {
behavior: 'smooth',
boundary: document.getElementById(
mode === 'drawer' ? 'nd-sidebar-mobile' : 'nd-sidebar',
),
scrollMode: 'if-needed',
});
}
}, [active, mode]);
}: ComponentProps<typeof Base.SidebarItem>) {
const depth = Base.useFolderDepth();
return (
<Link
ref={ref}
<Base.SidebarItem
className={cn(itemVariants(), className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
data-active={active}
className={cn(itemVariants({ active }), props.className)}
prefetch={prefetch}
>
{icon ?? (props.external ? <ExternalLink /> : null)}
{props.children}
</Link>
);
}
export function SidebarFolder({
defaultOpen = false,
...props
}: ComponentProps<'div'> & {
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
useOnChange(defaultOpen, (v) => {
if (v) setOpen(v);
});
return (
<Collapsible open={open} onOpenChange={setOpen} {...props}>
<FolderContext.Provider
value={useMemo(() => ({ open, setOpen }), [open])}
>
{props.children}
</FolderContext.Provider>
</Collapsible>
{children}
</Base.SidebarItem>
);
}
export function SidebarFolderTrigger({
className,
style,
...props
}: CollapsibleTriggerProps) {
const { open } = useFolderContext();
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const depth = Base.useFolderDepth();
return (
<CollapsibleTrigger
className={cn(itemVariants({ active: false }), 'w-full', className)}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</CollapsibleTrigger>
);
}
export function SidebarFolderLink(props: LinkProps) {
const { open, setOpen } = useFolderContext();
const { prefetch } = useInternalContext();
const pathname = usePathname();
const active =
props.href !== undefined && isActive(props.href, pathname, false);
return (
<Link
{...props}
data-active={active}
className={cn(itemVariants({ active }), 'w-full', props.className)}
onClick={(e) => {
if (
e.target instanceof Element &&
e.target.matches('[data-icon], [data-icon] *')
) {
setOpen(!open);
e.preventDefault();
} else {
setOpen(active ? !open : true);
}
<Base.SidebarFolderTrigger
data-active={false}
className={cn(itemVariants(), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
prefetch={prefetch}
{...props}
>
{props.children}
<ChevronDown
data-icon
className={cn('ms-auto transition-transform', !open && '-rotate-90')}
/>
</Link>
</Base.SidebarFolderTrigger>
);
}
export function SidebarFolderContent(props: CollapsibleContentProps) {
const { level, ...ctx } = useInternalContext();
export function SidebarFolderLink({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderLink>) {
const depth = Base.useFolderDepth();
return (
<CollapsibleContent
<Base.SidebarFolderLink
className={cn(itemVariants(), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderLink>
);
}
export function SidebarFolderContent({
className,
children,
...props
}: ComponentProps<typeof Base.SidebarFolderContent>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderContent
className={cn(
'relative',
level === 1 && [
depth === 1 && [
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
"**:data-[active=true]:before:content-[''] **:data-[active=true]:before:bg-fd-primary **:data-[active=true]:before:absolute **:data-[active=true]:before:w-px **:data-[active=true]:before:inset-y-2.5 **:data-[active=true]:before:start-2.5",
],
props.className,
className,
)}
style={
{
'--sidebar-item-offset': `calc(var(--spacing) * ${(level + 1) * 3})`,
...props.style,
} as object
}
>
<Context.Provider
value={useMemo(
() => ({
...ctx,
level: level + 1,
}),
[ctx, level],
)}
>
{props.children}
</Context.Provider>
</CollapsibleContent>
);
}
export function SidebarTrigger({
children,
...props
}: ComponentProps<'button'>) {
const { setOpen } = useSidebar();
return (
<button
{...props}
aria-label="Open Sidebar"
onClick={() => setOpen((prev) => !prev)}
>
{children}
</button>
</Base.SidebarFolderContent>
);
}
export function SidebarCollapseTrigger(props: ComponentProps<'button'>) {
const { collapsed, setCollapsed } = useSidebar();
return (
<button
type="button"
aria-label="Collapse Sidebar"
data-collapsed={collapsed}
{...props}
onClick={() => {
setCollapsed((prev) => !prev);
}}
>
{props.children}
</button>
);
}
function useFolderContext() {
const ctx = use(FolderContext);
if (!ctx) throw new Error('Missing sidebar folder');
return ctx;
}
function useInternalContext() {
const ctx = use(Context);
if (!ctx) throw new Error('<Sidebar /> component required.');
return ctx;
}
export interface SidebarComponents {
Item: FC<{ item: PageTree.Item }>;
Folder: FC<{ item: PageTree.Folder; level: number; children: ReactNode }>;
@@ -454,13 +207,14 @@ export interface SidebarComponents {
/**
* Render sidebar items from page tree
*/
export function SidebarPageTree(props: {
export function SidebarPageTree({
components,
}: {
components?: Partial<SidebarComponents>;
}) {
const { root } = useTreeContext();
return useMemo(() => {
const { Separator, Item, Folder } = props.components ?? {};
const { Separator, Item, Folder = PageTreeFolder } = components ?? {};
function renderSidebarList(
items: PageTree.Node[],
@@ -478,18 +232,10 @@ export function SidebarPageTree(props: {
}
if (item.type === 'folder') {
const children = renderSidebarList(item.children, level + 1);
if (Folder)
return (
<Folder key={i} item={item} level={level}>
{children}
</Folder>
);
return (
<PageTreeFolder key={i} item={item}>
{children}
</PageTreeFolder>
<Folder key={i} item={item} level={level}>
{renderSidebarList(item.children, level + 1)}
</Folder>
);
}
@@ -510,7 +256,7 @@ export function SidebarPageTree(props: {
return (
<Fragment key={root.$id}>{renderSidebarList(root.children, 1)}</Fragment>
);
}, [props.components, root]);
}, [components, root]);
}
function PageTreeFolder({
@@ -520,13 +266,12 @@ function PageTreeFolder({
item: PageTree.Folder;
children: ReactNode;
}) {
const { defaultOpenLevel, level } = useInternalContext();
const path = useTreePath();
return (
<SidebarFolder
defaultOpen={
(item.defaultOpen ?? defaultOpenLevel >= level) || path.includes(item)
defaultOpen={(value) =>
(item.defaultOpen ?? value) || path.includes(item)
}
>
{item.index ? (
@@ -552,10 +297,11 @@ function PageTreeFolder({
export function SidebarLinkItem({
item,
...props
}: {
}: HTMLAttributes<HTMLElement> & {
item: Exclude<LinkItemType, { type: 'icon' }>;
className?: string;
}) {
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
if (item.type === 'menu')
return (
<SidebarFolder {...props}>
@@ -578,8 +324,6 @@ export function SidebarLinkItem({
</SidebarFolder>
);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
return (
<SidebarItem
href={item.url}
@@ -5,7 +5,7 @@ import Link from 'fumadocs-core/link';
import { usePathname } from 'fumadocs-core/framework';
import { cn } from '@/utils/cn';
import { isTabActive } from '@/utils/is-active';
import { useSidebar } from '@/contexts/sidebar';
import { useSidebar } from '@/components/sidebar/base';
import {
Popover,
PopoverContent,
+1 -6
View File
@@ -10,7 +10,6 @@ import {
} from 'react';
import { DirectionProvider } from '@radix-ui/react-direction';
import type { DefaultSearchDialogProps } from '@/components/dialog/search-default';
import { SidebarProvider } from '@/contexts/sidebar';
import { SearchProvider, type SearchProviderProps } from '@/contexts/search';
import {
defaultTranslations,
@@ -121,11 +120,7 @@ export function RootProvider({
body = <I18nProvider {...i18n}>{body}</I18nProvider>;
}
return (
<DirectionProvider dir={dir}>
<SidebarProvider>{body}</SidebarProvider>
</DirectionProvider>
);
return <DirectionProvider dir={dir}>{body}</DirectionProvider>;
}
function I18nProvider({