mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI: headless sidebar component
This commit is contained in:
@@ -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 />`.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user